diff --git a/cmd/context/delete.go b/cmd/context/delete.go index 472fa9470..7c19e62b0 100644 --- a/cmd/context/delete.go +++ b/cmd/context/delete.go @@ -65,12 +65,7 @@ func (cmd *DeleteCmd) Run(ctx context.Context, context string) error { } delete(devsyConfig.Contexts, context) - if devsyConfig.DefaultContext == context { - devsyConfig.DefaultContext = "default" - } - if devsyConfig.OriginalContext == context { - devsyConfig.OriginalContext = "default" - } + resetContextReferences(devsyConfig, context) err = config.SaveConfig(devsyConfig) if err != nil { @@ -80,6 +75,15 @@ func (cmd *DeleteCmd) Run(ctx context.Context, context string) error { return nil } +func resetContextReferences(devsyConfig *config.Config, context string) { + if devsyConfig.DefaultContext == context { + devsyConfig.DefaultContext = "default" + } + if devsyConfig.OriginalContext == context { + devsyConfig.OriginalContext = "default" + } +} + // deleteContextSecrets aborts (rather than orphaning stored values) if the store // is unavailable or a delete fails, so the deletion can be retried intact. func deleteContextSecrets(devsyConfig *config.Config, contextName string) error { diff --git a/cmd/context/options.go b/cmd/context/options.go index 29fb2e3f4..9ca768275 100644 --- a/cmd/context/options.go +++ b/cmd/context/options.go @@ -60,42 +60,51 @@ func (cmd *OptionsCmd) Run(ctx context.Context, args []string) error { } switch mode { case output.ModePlain: - tableEntries := [][]string{} - for _, entry := range config.ContextOptions { - value := entryOptions[entry.Name].Value - - tableEntries = append(tableEntries, []string{ - entry.Name, - entry.Description, - entry.Default, - value, - }) - } - sort.SliceStable(tableEntries, func(i, j int) bool { - return tableEntries[i][0] < tableEntries[j][0] + printContextOptionsPlain(entryOptions) + case output.ModeJSON: + return printContextOptionsJSON(entryOptions) + } + + return nil +} + +func printContextOptionsPlain(entryOptions map[string]config.OptionValue) { + tableEntries := [][]string{} + for _, entry := range config.ContextOptions { + value := entryOptions[entry.Name].Value + + tableEntries = append(tableEntries, []string{ + entry.Name, + entry.Description, + entry.Default, + value, }) + } + sort.SliceStable(tableEntries, func(i, j int) bool { + return tableEntries[i][0] < tableEntries[j][0] + }) - table.Print([]string{ - "Name", - "Description", - "Default", - "Value", - }, tableEntries) - case output.ModeJSON: - options := map[string]optionWithValue{} - for _, entry := range config.ContextOptions { - options[entry.Name] = optionWithValue{ - ContextOption: entry, - Value: entryOptions[entry.Name].Value, - } - } + table.Print([]string{ + "Name", + "Description", + "Default", + "Value", + }, tableEntries) +} - out, err := json.MarshalIndent(options, "", " ") - if err != nil { - return err +func printContextOptionsJSON(entryOptions map[string]config.OptionValue) error { + options := map[string]optionWithValue{} + for _, entry := range config.ContextOptions { + options[entry.Name] = optionWithValue{ + ContextOption: entry, + Value: entryOptions[entry.Name].Value, } - fmt.Print(string(out)) } + out, err := json.MarshalIndent(options, "", " ") + if err != nil { + return err + } + fmt.Print(string(out)) //nolint:forbidigo // CLI stdout output return nil } diff --git a/cmd/ide/list.go b/cmd/ide/list.go index 0eb672ba5..0790ead8c 100644 --- a/cmd/ide/list.go +++ b/cmd/ide/list.go @@ -59,40 +59,49 @@ func (cmd *ListCmd) Run(ctx context.Context) error { } switch mode { case output.ModePlain: - tableEntries := [][]string{} - for _, entry := range ideparse.AllowedIDEs { - marker := "" - if devsyConfig.Current().DefaultIDE == string(entry.Name) { - marker = "*" - } - tableEntries = append(tableEntries, []string{ - string(entry.Name), - marker, - }) + printIDEsPlain(devsyConfig) + case output.ModeJSON: + return printIDEsJSON(devsyConfig) + } + + return nil +} + +func printIDEsPlain(devsyConfig *config.Config) { + tableEntries := [][]string{} + for _, entry := range ideparse.AllowedIDEs { + marker := "" + if devsyConfig.Current().DefaultIDE == string(entry.Name) { + marker = "*" } - sort.SliceStable(tableEntries, func(i, j int) bool { - return tableEntries[i][0] < tableEntries[j][0] + tableEntries = append(tableEntries, []string{ + string(entry.Name), + marker, }) + } + sort.SliceStable(tableEntries, func(i, j int) bool { + return tableEntries[i][0] < tableEntries[j][0] + }) - table.Print([]string{ - "Name", - "Default", - }, tableEntries) - case output.ModeJSON: - ides := []IDEWithDefault{} - for _, entry := range ideparse.AllowedIDEs { - ides = append(ides, IDEWithDefault{ - AllowedIDE: entry, - Default: devsyConfig.Current().DefaultIDE == string(entry.Name), - }) - } + table.Print([]string{ + "Name", + "Default", + }, tableEntries) +} - out, err := json.MarshalIndent(ides, "", " ") - if err != nil { - return err - } - _, _ = fmt.Fprintln(os.Stdout, string(out)) +func printIDEsJSON(devsyConfig *config.Config) error { + ides := []IDEWithDefault{} + for _, entry := range ideparse.AllowedIDEs { + ides = append(ides, IDEWithDefault{ + AllowedIDE: entry, + Default: devsyConfig.Current().DefaultIDE == string(entry.Name), + }) } + out, err := json.MarshalIndent(ides, "", " ") + if err != nil { + return err + } + _, _ = fmt.Fprintln(os.Stdout, string(out)) return nil } diff --git a/cmd/ide/options.go b/cmd/ide/options.go index 62ee9225a..393ed8dbb 100644 --- a/cmd/ide/options.go +++ b/cmd/ide/options.go @@ -64,41 +64,50 @@ func (cmd *OptionsCmd) Run(ctx context.Context, ide string) error { } switch mode { case output.ModePlain: - tableEntries := [][]string{} - for optionName, entry := range ideOptions { - value := values[optionName].Value - tableEntries = append(tableEntries, []string{ - optionName, - entry.Description, - entry.Default, - value, - }) - } - sort.SliceStable(tableEntries, func(i, j int) bool { - return tableEntries[i][0] < tableEntries[j][0] + printIDEOptionsPlain(ideOptions, values) + case output.ModeJSON: + return printIDEOptionsJSON(ideOptions, values) + } + + return nil +} + +func printIDEOptionsPlain(ideOptions ide.Options, values map[string]config.OptionValue) { + tableEntries := [][]string{} + for optionName, entry := range ideOptions { + value := values[optionName].Value + tableEntries = append(tableEntries, []string{ + optionName, + entry.Description, + entry.Default, + value, }) + } + sort.SliceStable(tableEntries, func(i, j int) bool { + return tableEntries[i][0] < tableEntries[j][0] + }) - table.Print([]string{ - "Name", - "Description", - "Default", - "Value", - }, tableEntries) - case output.ModeJSON: - options := map[string]optionWithValue{} - for optionName, entry := range ideOptions { - options[optionName] = optionWithValue{ - Option: entry, - Value: values[optionName].Value, - } - } + table.Print([]string{ + "Name", + "Description", + "Default", + "Value", + }, tableEntries) +} - out, err := json.MarshalIndent(options, "", " ") - if err != nil { - return err +func printIDEOptionsJSON(ideOptions ide.Options, values map[string]config.OptionValue) error { + options := map[string]optionWithValue{} + for optionName, entry := range ideOptions { + options[optionName] = optionWithValue{ + Option: entry, + Value: values[optionName].Value, } - _, _ = fmt.Fprintln(os.Stdout, string(out)) } + out, err := json.MarshalIndent(options, "", " ") + if err != nil { + return err + } + _, _ = fmt.Fprintln(os.Stdout, string(out)) return nil } diff --git a/cmd/internal/agentcontainer/credentials_server.go b/cmd/internal/agentcontainer/credentials_server.go index 8ff96ccae..e00b76230 100644 --- a/cmd/internal/agentcontainer/credentials_server.go +++ b/cmd/internal/agentcontainer/credentials_server.go @@ -96,21 +96,11 @@ func (cmd *CredentialsServerCmd) Run(ctx context.Context, port int) error { } // this message serves as a ping to the client - _, err = tunnelClient.Ping(ctx, &tunnel.Empty{}) - if err != nil { + if _, err := tunnelClient.Ping(ctx, &tunnel.Empty{}); err != nil { return fmt.Errorf("ping client: %w", err) } - // forward ports - if cmd.ForwardPorts { - go func() { - log.Debugf("Start watching & forwarding open ports") - err = forwardPorts(ctx, tunnelClient) - if err != nil { - log.Errorf("error forwarding ports: %v", err) - } - }() - } + cmd.maybeForwardPorts(ctx, tunnelClient) addr := net.JoinHostPort("localhost", strconv.Itoa(port)) if ok, err := portpkg.IsAvailable(addr); !ok || err != nil { @@ -119,63 +109,105 @@ func (cmd *CredentialsServerCmd) Run(ctx context.Context, port int) error { } // configure docker credential helper - if cmd.ConfigureDockerHelper { - err = dockercredentials.ConfigureCredentialsContainer(cmd.User, port) - if err != nil { - return err - } + if err := cmd.configureDockerHelper(port); err != nil { + return err } // configure git user - err = configureGitUserLocally(ctx, cmd.User, tunnelClient) - if err != nil { + if err := configureGitUserLocally(ctx, cmd.User, tunnelClient); err != nil { log.Debugf("Error configuring git user: %v", err) return err } // configure git credential helper - if cmd.ConfigureGitHelper { - binaryPath, err := os.Executable() - if err != nil { - return err - } - err = gitcredentials.ConfigureHelper(ctx, binaryPath, cmd.User, port) - if err != nil { - return fmt.Errorf("configure git helper: %w", err) - } - - // cleanup when we are done. This defer runs after the server loop - // returns on shutdown, when ctx is already canceled — use an uncanceled - // context so the helper is actually removed instead of aborting early. - cleanupCtx := context.WithoutCancel(ctx) - defer func(userName string) { - _ = gitcredentials.RemoveHelper(cleanupCtx, userName) - }(cmd.User) + cleanupGitHelper, err := cmd.configureGitCredentialHelper(ctx, port) + if err != nil { + return err } + defer cleanupGitHelper() // configure git ssh signature helper -- non-fatal so that a signing // setup failure does not take down the entire credentials server // (git/docker credential forwarding, port forwarding, etc.) - if cmd.GitUserSigningKey != "" { - decodedKey, err := base64.StdEncoding.DecodeString(cmd.GitUserSigningKey) - if err != nil { - log.Errorf("Failed to decode git SSH signing key, signing will be unavailable: %v", err) - } else { - err = gitsshsigning.ConfigureHelper(cmd.User, string(decodedKey)) - if err != nil { - log.Errorf( - "Failed to configure git SSH signature helper, signing will be unavailable: %v", - err, - ) - } else { - defer func(userName string) { - _ = gitsshsigning.RemoveHelper(userName) - }(cmd.User) - } + cleanupGitSigning := cmd.configureGitSigningKey() + defer cleanupGitSigning() + + return credentials.RunCredentialsServer(ctx, port, tunnelClient) +} + +func (cmd *CredentialsServerCmd) maybeForwardPorts( + ctx context.Context, + tunnelClient tunnel.TunnelClient, +) { + if !cmd.ForwardPorts { + return + } + go func() { + log.Debugf("Start watching & forwarding open ports") + if err := forwardPorts(ctx, tunnelClient); err != nil { + log.Errorf("error forwarding ports: %v", err) } + }() +} + +func (cmd *CredentialsServerCmd) configureDockerHelper(port int) error { + if !cmd.ConfigureDockerHelper { + return nil } + return dockercredentials.ConfigureCredentialsContainer(cmd.User, port) +} - return credentials.RunCredentialsServer(ctx, port, tunnelClient) +func (cmd *CredentialsServerCmd) configureGitCredentialHelper( + ctx context.Context, + port int, +) (func(), error) { + noop := func() {} + if !cmd.ConfigureGitHelper { + return noop, nil + } + + binaryPath, err := os.Executable() + if err != nil { + return noop, err + } + if err := gitcredentials.ConfigureHelper(ctx, binaryPath, cmd.User, port); err != nil { + return noop, fmt.Errorf("configure git helper: %w", err) + } + + // cleanup when we are done. This defer runs after the server loop + // returns on shutdown, when ctx is already canceled — use an uncanceled + // context so the helper is actually removed instead of aborting early. + cleanupCtx := context.WithoutCancel(ctx) + userName := cmd.User + return func() { + _ = gitcredentials.RemoveHelper(cleanupCtx, userName) + }, nil +} + +func (cmd *CredentialsServerCmd) configureGitSigningKey() func() { + noop := func() {} + if cmd.GitUserSigningKey == "" { + return noop + } + + decodedKey, err := base64.StdEncoding.DecodeString(cmd.GitUserSigningKey) + if err != nil { + log.Errorf("Failed to decode git SSH signing key, signing will be unavailable: %v", err) + return noop + } + + if err := gitsshsigning.ConfigureHelper(cmd.User, string(decodedKey)); err != nil { + log.Errorf( + "Failed to configure git SSH signature helper, signing will be unavailable: %v", + err, + ) + return noop + } + + userName := cmd.User + return func() { + _ = gitsshsigning.RemoveHelper(userName) + } } func configureGitUserLocally( @@ -187,40 +219,54 @@ func configureGitUserLocally( localGitUser, err := gitcredentials.GetUser(ctx, userName, "") if err != nil { return err - } else if localGitUser.Name != "" && localGitUser.Email != "" { + } + if localGitUser.Name != "" && localGitUser.Email != "" { return nil } // set user & email if not found - response, err := client.GitUser(ctx, &tunnel.Empty{}) + gitUser, err := fetchRemoteGitUser(ctx, client) if err != nil { - return fmt.Errorf("retrieve git user: %w", err) - } - - // parse git user from response - gitUser := &gitcredentials.GitUser{} - err = json.Unmarshal([]byte(response.Message), gitUser) - if err != nil { - return fmt.Errorf("decode git user: %w", err) + return err } // don't override what is already there - if localGitUser.Name != "" { - gitUser.Name = "" - } - if localGitUser.Email != "" { - gitUser.Email = "" - } + clearKnownGitUserFields(localGitUser, gitUser) // set git user - err = gitcredentials.SetUser(ctx, userName, gitUser) - if err != nil { + if err := gitcredentials.SetUser(ctx, userName, gitUser); err != nil { return fmt.Errorf("set git user & email: %w", err) } return nil } +func fetchRemoteGitUser( + ctx context.Context, + client tunnel.TunnelClient, +) (*gitcredentials.GitUser, error) { + response, err := client.GitUser(ctx, &tunnel.Empty{}) + if err != nil { + return nil, fmt.Errorf("retrieve git user: %w", err) + } + + gitUser := &gitcredentials.GitUser{} + if err := json.Unmarshal([]byte(response.Message), gitUser); err != nil { + return nil, fmt.Errorf("decode git user: %w", err) + } + + return gitUser, nil +} + +func clearKnownGitUserFields(local, remote *gitcredentials.GitUser) { + if local.Name != "" { + remote.Name = "" + } + if local.Email != "" { + remote.Email = "" + } +} + func forwardPorts(ctx context.Context, client tunnel.TunnelClient) error { opts := portOptionsFromResult() return netstat.NewWatcher(&forwarder{ctx: ctx, client: client}, opts...).Run(ctx) diff --git a/cmd/internal/agentcontainer/daemon.go b/cmd/internal/agentcontainer/daemon.go index e5bbe5fd9..cdf0d91ed 100644 --- a/cmd/internal/agentcontainer/daemon.go +++ b/cmd/internal/agentcontainer/daemon.go @@ -68,30 +68,56 @@ func (cmd *DaemonCmd) Run(c *cobra.Command, args []string) error { return err } - var timeoutDuration time.Duration - if cmd.Config.Timeout != "" { - var err error - timeoutDuration, err = time.ParseDuration(cmd.Config.Timeout) - if err != nil { - return fmt.Errorf("failed to parse timeout duration: %w", err) - } - if timeoutDuration > 0 { - if err := os.WriteFile( - config2.ContainerActivityFile, - nil, - 0o666, - ); err != nil { // #nosec G306 - return fmt.Errorf("failed to create activity file: %w", err) - } - if err := os.Chmod(config2.ContainerActivityFile, 0o666); err != nil { // #nosec G302 - return fmt.Errorf("failed to set activity file permissions: %w", err) - } - } + timeoutDuration, err := cmd.setupTimeout() + if err != nil { + return err } ctx, stop := signal.NotifyContext(c.Context(), os.Interrupt, syscall.SIGTERM) g, ctx := errgroup.WithContext(ctx) + cmd.startDaemonTasks(ctx, g, timeoutDuration) + + err = g.Wait() + stop() // Restore default signal handling before exiting. + if err != nil { + log.Errorf("daemon error: %v", err) + os.Exit(1) + } + os.Exit(0) + return nil // Unreachable but needed. +} + +func (cmd *DaemonCmd) setupTimeout() (time.Duration, error) { + if cmd.Config.Timeout == "" { + return 0, nil + } + + timeoutDuration, err := time.ParseDuration(cmd.Config.Timeout) + if err != nil { + return 0, fmt.Errorf("failed to parse timeout duration: %w", err) + } + if timeoutDuration > 0 { + if err := os.WriteFile( // #nosec G306 + config2.ContainerActivityFile, + nil, + 0o666, + ); err != nil { + return 0, fmt.Errorf("failed to create activity file: %w", err) + } + if err := os.Chmod(config2.ContainerActivityFile, 0o666); err != nil { // #nosec G302 + return 0, fmt.Errorf("failed to set activity file permissions: %w", err) + } + } + + return timeoutDuration, nil +} + +func (cmd *DaemonCmd) startDaemonTasks( + ctx context.Context, + g *errgroup.Group, + timeoutDuration time.Duration, +) { var tasksStarted bool // Start process reaper. @@ -133,15 +159,6 @@ func (cmd *DaemonCmd) Run(c *cobra.Command, args []string) error { return nil }) } - - err := g.Wait() - stop() // Restore default signal handling before exiting. - if err != nil { - log.Errorf("daemon error: %v", err) - os.Exit(1) - } - os.Exit(0) - return nil // Unreachable but needed. } // loadConfig loads the daemon configuration from base64-encoded JSON. diff --git a/cmd/internal/agentcontainer/setup.go b/cmd/internal/agentcontainer/setup.go index 80980222c..211991729 100644 --- a/cmd/internal/agentcontainer/setup.go +++ b/cmd/internal/agentcontainer/setup.go @@ -613,79 +613,71 @@ func fillContainerEnv(setupInfo *config.Result) error { return nil } +var vscodeFlavors = map[string]vscode.Flavor{ + string(config2.IDEVSCode): vscode.FlavorStable, + string(config2.IDEVSCodeInsiders): vscode.FlavorInsiders, + string(config2.IDECursor): vscode.FlavorCursor, + string(config2.IDEPositron): vscode.FlavorPositron, + string(config2.IDECodium): vscode.FlavorCodium, + string(config2.IDEWindsurf): vscode.FlavorWindsurf, + string(config2.IDEAntigravity): vscode.FlavorAntigravity, + string(config2.IDEBob): vscode.FlavorBob, +} + +type jetbrainsServerFactory func( + string, + map[string]config2.OptionValue, +) *jetbrains.GenericJetBrainsServer + +var jetbrainsServers = map[string]jetbrainsServerFactory{ + string(config2.IDEGoland): jetbrains.NewGolandServer, + string(config2.IDERustRover): jetbrains.NewRustRoverServer, + string(config2.IDEPyCharm): jetbrains.NewPyCharmServer, + string(config2.IDEPhpStorm): jetbrains.NewPhpStorm, + string(config2.IDEIntellij): jetbrains.NewIntellij, + string(config2.IDECLion): jetbrains.NewCLionServer, + string(config2.IDERider): jetbrains.NewRiderServer, + string(config2.IDERubyMine): jetbrains.NewRubyMineServer, + string(config2.IDEWebStorm): jetbrains.NewWebStormServer, + string(config2.IDEDataSpell): jetbrains.NewDataSpellServer, +} + func (cmd *SetupContainerCmd) installIDE( setupInfo *config.Result, ide *provider2.WorkspaceIDEConfig, ) error { + if flavor, ok := vscodeFlavors[ide.Name]; ok { + return cmd.setupVSCode(setupInfo, ide.Options, flavor) + } + if newServer, ok := jetbrainsServers[ide.Name]; ok { + return newServer(config.GetRemoteUser(setupInfo), ide.Options).Install(setupInfo) + } + switch ide.Name { case string(config2.IDENone): return nil - case string(config2.IDEVSCode): - return cmd.setupVSCode(setupInfo, ide.Options, vscode.FlavorStable) - case string(config2.IDEVSCodeInsiders): - return cmd.setupVSCode(setupInfo, ide.Options, vscode.FlavorInsiders) - case string(config2.IDECursor): - return cmd.setupVSCode(setupInfo, ide.Options, vscode.FlavorCursor) - case string(config2.IDEPositron): - return cmd.setupVSCode(setupInfo, ide.Options, vscode.FlavorPositron) - case string(config2.IDECodium): - return cmd.setupVSCode(setupInfo, ide.Options, vscode.FlavorCodium) - case string(config2.IDEWindsurf): - return cmd.setupVSCode(setupInfo, ide.Options, vscode.FlavorWindsurf) - case string(config2.IDEAntigravity): - return cmd.setupVSCode(setupInfo, ide.Options, vscode.FlavorAntigravity) - case string(config2.IDEBob): - return cmd.setupVSCode(setupInfo, ide.Options, vscode.FlavorBob) case string(config2.IDEOpenVSCode), string(config2.IDECodeServer), string(config2.IDEVSCodeWeb): return cmd.setupBrowserIDE(ide.Name, setupInfo, ide.Options) - case string(config2.IDEGoland): - return jetbrains.NewGolandServer(config.GetRemoteUser(setupInfo), ide.Options). - Install(setupInfo) - case string(config2.IDERustRover): - return jetbrains.NewRustRoverServer(config.GetRemoteUser(setupInfo), ide.Options). - Install(setupInfo) - case string(config2.IDEPyCharm): - return jetbrains.NewPyCharmServer(config.GetRemoteUser(setupInfo), ide.Options). - Install(setupInfo) - case string(config2.IDEPhpStorm): - return jetbrains.NewPhpStorm(config.GetRemoteUser(setupInfo), ide.Options). - Install(setupInfo) - case string(config2.IDEIntellij): - return jetbrains.NewIntellij(config.GetRemoteUser(setupInfo), ide.Options). - Install(setupInfo) - case string(config2.IDECLion): - return jetbrains.NewCLionServer(config.GetRemoteUser(setupInfo), ide.Options). - Install(setupInfo) - case string(config2.IDERider): - return jetbrains.NewRiderServer(config.GetRemoteUser(setupInfo), ide.Options). - Install(setupInfo) - case string(config2.IDERubyMine): - return jetbrains.NewRubyMineServer(config.GetRemoteUser(setupInfo), ide.Options). - Install(setupInfo) - case string(config2.IDEWebStorm): - return jetbrains.NewWebStormServer(config.GetRemoteUser(setupInfo), ide.Options). - Install(setupInfo) - case string(config2.IDEDataSpell): - return jetbrains.NewDataSpellServer(config.GetRemoteUser(setupInfo), ide.Options). - Install(setupInfo) + } + + return installNotebookIDE(setupInfo, ide) +} + +func installNotebookIDE( + setupInfo *config.Result, + ide *provider2.WorkspaceIDEConfig, +) error { + user := config.GetRemoteUser(setupInfo) + folder := setupInfo.SubstitutionContext.ContainerWorkspaceFolder + switch ide.Name { case string(config2.IDEFleet): - return fleet.NewFleetServer(config.GetRemoteUser(setupInfo), ide.Options). - Install(setupInfo.SubstitutionContext.ContainerWorkspaceFolder) + return fleet.NewFleetServer(user, ide.Options).Install(folder) case string(config2.IDEJupyterNotebook): - return jupyter.NewJupyterNotebookServer( - setupInfo.SubstitutionContext.ContainerWorkspaceFolder, - config.GetRemoteUser(setupInfo), ide.Options). - Install() + return jupyter.NewJupyterNotebookServer(folder, user, ide.Options).Install() case string(config2.IDEMarimo): - return marimo.NewMarimoServer( - setupInfo.SubstitutionContext.ContainerWorkspaceFolder, - config.GetRemoteUser(setupInfo), ide.Options). - Install() + return marimo.NewMarimoServer(folder, user, ide.Options).Install() case string(config2.IDERStudio): - return rstudio.NewRStudioServer( - setupInfo.SubstitutionContext.ContainerWorkspaceFolder, - config.GetRemoteUser(setupInfo), ide.Options). - Install() + return rstudio.NewRStudioServer(folder, user, ide.Options).Install() } return nil diff --git a/cmd/internal/agentworkspace/build.go b/cmd/internal/agentworkspace/build.go index 8a0870626..8687e5217 100644 --- a/cmd/internal/agentworkspace/build.go +++ b/cmd/internal/agentworkspace/build.go @@ -7,6 +7,7 @@ import ( "github.com/devsy-org/devsy/cmd/flags" "github.com/devsy-org/devsy/pkg/agent" + "github.com/devsy-org/devsy/pkg/devcontainer" cliflags "github.com/devsy-org/devsy/pkg/flags" "github.com/devsy-org/devsy/pkg/flags/names" "github.com/devsy-org/devsy/pkg/log" @@ -82,6 +83,14 @@ func (cmd *BuildCmd) Run(ctx context.Context) error { return err } + return buildAndPushImages(ctx, runner, workspaceInfo) +} + +func buildAndPushImages( + ctx context.Context, + runner devcontainer.Runner, + workspaceInfo *provider2.AgentWorkspaceInfo, +) error { // if there is no platform specified, we use empty to let // the builder find out itself. platforms := workspaceInfo.CLIOptions.Platforms @@ -89,7 +98,6 @@ func (cmd *BuildCmd) Run(ctx context.Context) error { platforms = []string{""} } - // build and push images for _, platform := range platforms { // build the image imageName, err := runner.Build(ctx, provider2.BuildOptions{ diff --git a/cmd/internal/check_provider_update.go b/cmd/internal/check_provider_update.go index 471dede3f..2d7a7421f 100644 --- a/cmd/internal/check_provider_update.go +++ b/cmd/internal/check_provider_update.go @@ -57,54 +57,70 @@ func (cmd *CheckProviderUpdateCmd) Run( } providerName := args[0] - providerSourceRaw, err := workspace.ResolveProviderSource( - devsyConfig, - providerName, - ) + providerSourceRaw, currentVersion, err := loadCurrentProvider(devsyConfig, providerName) if err != nil { - return fmt.Errorf("provider %s doesn't exist", providerName) + return err } - // retrieve current config for provider - allProviders, err := workspace.LoadAllProviders(devsyConfig) + latestProviderConfig, err := loadLatestProvider(ctx, providerSourceRaw) if err != nil { return err } - currentProvider, ok := allProviders[providerName] - if !ok { - return errProviderNotFound - } - latestProviderConfig, err := loadLatestProvider(ctx, providerSourceRaw) + versionCheck, err := resolveProviderVersions(currentVersion, latestProviderConfig.Version) if err != nil { return err } - currentProviderVersion, err := semver.Parse( - strings.TrimPrefix(currentProvider.Config.Version, "v"), - ) + + out, err := json.Marshal(versionCheck) if err != nil { return err } - latestProviderVersion, err := semver.Parse( - strings.TrimPrefix(latestProviderConfig.Version, "v"), - ) + fmt.Println(string(out)) //nolint:forbidigo // CLI stdout output + + return nil +} + +func loadCurrentProvider( + devsyConfig *config.Config, + providerName string, +) (source string, currentVersion string, err error) { + source, err = workspace.ResolveProviderSource(devsyConfig, providerName) if err != nil { - return err + return "", "", fmt.Errorf("provider %s doesn't exist", providerName) } - versionCheck := providerVersionCheck{UpdateAvailable: false} - // check if new version is newer - if latestProviderVersion.GT(currentProviderVersion) { - versionCheck.UpdateAvailable = true - versionCheck.LatestVersion = latestProviderConfig.Version + allProviders, err := workspace.LoadAllProviders(devsyConfig) + if err != nil { + return "", "", err } - out, err := json.Marshal(versionCheck) + currentProvider, ok := allProviders[providerName] + if !ok { + return "", "", errProviderNotFound + } + + return source, currentProvider.Config.Version, nil +} + +func resolveProviderVersions( + current, latest string, +) (providerVersionCheck, error) { + currentVersion, err := semver.Parse(strings.TrimPrefix(current, "v")) if err != nil { - return err + return providerVersionCheck{}, err + } + latestVersion, err := semver.Parse(strings.TrimPrefix(latest, "v")) + if err != nil { + return providerVersionCheck{}, err } - fmt.Println(string(out)) - return nil + versionCheck := providerVersionCheck{UpdateAvailable: false} + if latestVersion.GT(currentVersion) { + versionCheck.UpdateAvailable = true + versionCheck.LatestVersion = latest + } + + return versionCheck, nil } func loadLatestProvider( diff --git a/cmd/internal/runusercommands.go b/cmd/internal/runusercommands.go index 1bba6762c..71608665e 100644 --- a/cmd/internal/runusercommands.go +++ b/cmd/internal/runusercommands.go @@ -312,15 +312,8 @@ func (cmd *RunUserCommandsCmd) loadContainerIDConfig( return nil, fmt.Errorf("merge configuration: %w", err) } - if cmd.OverrideConfig != "" { - if err := devcconfig.MergeExtraRemoteEnv( - ctx, - mergedConfig, - cmd.OverrideConfig, - ); err != nil { - _ = devcconfig.WriteErrorJSON(os.Stderr, err.Error()) - return nil, fmt.Errorf("apply override config: %w", err) - } + if err := cmd.applyOverrideConfig(ctx, mergedConfig); err != nil { + return nil, err } return &devcconfig.Result{ @@ -329,6 +322,20 @@ func (cmd *RunUserCommandsCmd) loadContainerIDConfig( }, nil } +func (cmd *RunUserCommandsCmd) applyOverrideConfig( + ctx context.Context, + mergedConfig *devcconfig.MergedDevContainerConfig, +) error { + if cmd.OverrideConfig == "" { + return nil + } + if err := devcconfig.MergeExtraRemoteEnv(ctx, mergedConfig, cmd.OverrideConfig); err != nil { + _ = devcconfig.WriteErrorJSON(os.Stderr, err.Error()) + return fmt.Errorf("apply override config: %w", err) + } + return nil +} + func (cmd *RunUserCommandsCmd) buildCLIRemoteEnvArgs() []string { if len(cmd.RemoteEnv) == 0 { return nil @@ -381,15 +388,8 @@ func (cmd *RunUserCommandsCmd) resolveContainer( return nil, nil, fmt.Errorf("no workspace result found; lifecycle commands unavailable") } - if cmd.OverrideConfig != "" { - if err := devcconfig.MergeExtraRemoteEnv( - ctx, - result.MergedConfig, - cmd.OverrideConfig, - ); err != nil { - _ = devcconfig.WriteErrorJSON(os.Stderr, err.Error()) - return nil, nil, fmt.Errorf("apply override config: %w", err) - } + if err := cmd.applyOverrideConfig(ctx, result.MergedConfig); err != nil { + return nil, nil, err } envArgs := workspace.BuildLifecycleEnvArgs(result) @@ -426,32 +426,54 @@ func (cmd *RunUserCommandsCmd) runLifecycleHooks( } waitForBoundary := resolveWaitForBoundary(result) + boundaryName := hooks[waitForBoundary].name for i, hook := range hooks { - if cmd.Prebuild && i >= 2 { - log.Infof( - "stopping lifecycle execution (%s: after %s)", - names.Flag(names.Prebuild), - updateContentCommand, - ) - return nil - } - if cmd.SkipNonBlockingCommands && i > waitForBoundary { - log.Infof( - "stopping lifecycle execution (--skip-non-blocking-commands: after %s)", - hooks[waitForBoundary].name, - ) + if cmd.shouldStopLifecycle(i, waitForBoundary, boundaryName) { return nil } if hook.skip { log.Infof("skipping %s (--skip flag set)", hook.name) continue } - for _, h := range hook.cmds { - if err := workspace.ExecLifecycleHook(params, hook.name, h); err != nil { - _ = devcconfig.WriteErrorJSON(os.Stderr, err.Error()) - return fmt.Errorf("lifecycle hooks: %s: %w", hook.name, err) - } + if err := execLifecycleHooks(params, hook.name, hook.cmds); err != nil { + return err + } + } + return nil +} + +func (cmd *RunUserCommandsCmd) shouldStopLifecycle( + i, waitForBoundary int, + boundaryName string, +) bool { + if cmd.Prebuild && i >= 2 { + log.Infof( + "stopping lifecycle execution (%s: after %s)", + names.Flag(names.Prebuild), + updateContentCommand, + ) + return true + } + if cmd.SkipNonBlockingCommands && i > waitForBoundary { + log.Infof( + "stopping lifecycle execution (--skip-non-blocking-commands: after %s)", + boundaryName, + ) + return true + } + return false +} + +func execLifecycleHooks( + params *workspace.LifecycleExecParams, + name string, + cmds []types.LifecycleHook, +) error { + for _, h := range cmds { + if err := workspace.ExecLifecycleHook(params, name, h); err != nil { + _ = devcconfig.WriteErrorJSON(os.Stderr, err.Error()) + return fmt.Errorf("lifecycle hooks: %s: %w", name, err) } } return nil diff --git a/cmd/internal/ssh_git_clone.go b/cmd/internal/ssh_git_clone.go index 2f0d538b8..fe7b233bc 100644 --- a/cmd/internal/ssh_git_clone.go +++ b/cmd/internal/ssh_git_clone.go @@ -38,33 +38,46 @@ func NewSSHGitCloneCmd() *cobra.Command { } func (cmd *SSHGitClone) Run(ctx context.Context, args []string) error { + host, sshCmdArgs, err := parseSSHArgs(args) + if err != nil { + return err + } + + user, addr, err := parseSSHHost(host) + if err != nil { + return err + } + + sshConfig, err := getConfig(user, cmd.KeyFiles) + if err != nil { + return err + } + + return runSSHSession(sshConfig, net.JoinHostPort(addr, cmd.Port), sshCmdArgs) +} + +func parseSSHArgs(args []string) (host string, sshCmdArgs []string, err error) { if len(args) < 2 { - return fmt.Errorf( + return "", nil, fmt.Errorf( "expected args in format: {user}@{host} {commands...}, received %q", strings.Join(args, " "), ) } - host := args[0] - sshCmdArgs := args[1:] + host = args[0] + sshCmdArgs = args[1:] if len(host) == 0 || len(sshCmdArgs) == 0 { - return fmt.Errorf( + return "", nil, fmt.Errorf( "unexpected input: host: %s, args: %s", host, strings.Join(sshCmdArgs, " "), ) } - user, addr, err := parseSSHHost(host) - if err != nil { - return err - } - - sshConfig, err := getConfig(user, cmd.KeyFiles) - if err != nil { - return err - } + return host, sshCmdArgs, nil +} - sshClient, err := ssh.Dial("tcp", net.JoinHostPort(addr, cmd.Port), sshConfig) +func runSSHSession(sshConfig *ssh.ClientConfig, addr string, sshCmdArgs []string) error { + sshClient, err := ssh.Dial("tcp", addr, sshConfig) if err != nil { return err } @@ -79,12 +92,8 @@ func (cmd *SSHGitClone) Run(ctx context.Context, args []string) error { sess.Stdin = os.Stdin sess.Stdout = os.Stdout sess.Stderr = os.Stderr - err = sess.Run(command2.Quote(sshCmdArgs)) - if err != nil { - return err - } - return nil + return sess.Run(command2.Quote(sshCmdArgs)) } func getConfig(userName string, keyFilePaths []string) (*ssh.ClientConfig, error) { diff --git a/cmd/machine/list.go b/cmd/machine/list.go index 23ad0bed1..acd15efaa 100644 --- a/cmd/machine/list.go +++ b/cmd/machine/list.go @@ -61,53 +61,63 @@ func (cmd *ListCmd) Run(ctx context.Context) error { } switch mode { case output.ModePlain: - tableEntries := [][]string{} - for _, entry := range entries { - machineConfig, err := provider.LoadMachineConfig( - devsyConfig.DefaultContext, - entry.Name(), - ) - if err != nil { - return fmt.Errorf("load machine config: %w", err) - } + return printMachinesPlain(devsyConfig, entries) + case output.ModeJSON: + return printMachinesJSON(devsyConfig, entries) + } + + return nil +} - tableEntries = append(tableEntries, []string{ - machineConfig.ID, - machineConfig.Provider.Name, - time.Since(machineConfig.CreationTimestamp.Time).Round(1 * time.Second).String(), - }) +func printMachinesPlain(devsyConfig *config.Config, entries []os.DirEntry) error { + tableEntries := [][]string{} + for _, entry := range entries { + machineConfig, err := provider.LoadMachineConfig( + devsyConfig.DefaultContext, + entry.Name(), + ) + if err != nil { + return fmt.Errorf("load machine config: %w", err) } - sort.SliceStable(tableEntries, func(i, j int) bool { - return tableEntries[i][0] < tableEntries[j][0] + + tableEntries = append(tableEntries, []string{ + machineConfig.ID, + machineConfig.Provider.Name, + time.Since(machineConfig.CreationTimestamp.Time).Round(1 * time.Second).String(), }) + } + sort.SliceStable(tableEntries, func(i, j int) bool { + return tableEntries[i][0] < tableEntries[j][0] + }) - table.Print([]string{ - "Name", - "Provider", - "Age", - }, tableEntries) - case output.ModeJSON: - tableEntries := []*provider.Machine{} - for _, entry := range entries { - machineConfig, err := provider.LoadMachineConfig( - devsyConfig.DefaultContext, - entry.Name(), - ) - if err != nil { - return fmt.Errorf("load machine config: %w", err) - } + table.Print([]string{ + "Name", + "Provider", + "Age", + }, tableEntries) + return nil +} - tableEntries = append(tableEntries, machineConfig) - } - sort.SliceStable(tableEntries, func(i, j int) bool { - return tableEntries[i].ID < tableEntries[j].ID - }) - out, err := json.Marshal(tableEntries) +func printMachinesJSON(devsyConfig *config.Config, entries []os.DirEntry) error { + tableEntries := []*provider.Machine{} + for _, entry := range entries { + machineConfig, err := provider.LoadMachineConfig( + devsyConfig.DefaultContext, + entry.Name(), + ) if err != nil { - return err + return fmt.Errorf("load machine config: %w", err) } - fmt.Print(string(out)) - } + tableEntries = append(tableEntries, machineConfig) + } + sort.SliceStable(tableEntries, func(i, j int) bool { + return tableEntries[i].ID < tableEntries[j].ID + }) + out, err := json.Marshal(tableEntries) + if err != nil { + return err + } + fmt.Print(string(out)) //nolint:forbidigo // CLI stdout output return nil } diff --git a/cmd/pro/cluster/add.go b/cmd/pro/cluster/add.go index 174bb12c2..85d1ef240 100644 --- a/cmd/pro/cluster/add.go +++ b/cmd/pro/cluster/add.go @@ -18,6 +18,7 @@ import ( "github.com/devsy-org/devsy/pkg/log" "github.com/devsy-org/devsy/pkg/platform" "github.com/devsy-org/devsy/pkg/platform/client" + "github.com/devsy-org/devsy/pkg/platform/kube" "github.com/devsy-org/devsy/pkg/survey" "github.com/devsy-org/devsy/pkg/workspace" "github.com/spf13/cobra" @@ -26,6 +27,7 @@ import ( "k8s.io/apimachinery/pkg/util/wait" "k8s.io/client-go/kubernetes" "k8s.io/client-go/tools/clientcmd" + "sigs.k8s.io/yaml" ) type ClusterCmd struct { @@ -124,49 +126,129 @@ func NewAddCmd(globalFlags *proflags.GlobalFlags) *cobra.Command { } func (cmd *ClusterCmd) Run(ctx context.Context, args []string) error { - devsyConfig, err := config.LoadConfig(cmd.Context, "") + clusterName := args[0] + + setup, err := cmd.setupCluster(ctx, clusterName) if err != nil { return err } + managementClient := setup.managementClient - cmd.Host, err = ensureHost(devsyConfig, cmd.Host) + helmArgs := cmd.buildHelmArgs(setup.chartVersion, setup.accessKey) + + secretsFile, err := writeAgentSecretsFile(setup.accessKey) if err != nil { return err } + if secretsFile != "" { + defer func() { _ = os.Remove(secretsFile) }() + helmArgs = append(helmArgs, "--values", secretsFile) + } - // Get clusterName from command argument - clusterName := args[0] + clientset, err := loadKubeClientset(cmd.KubeContext) + if err != nil { + return err + } + + if err := installAgent(ctx, clientset, cmd.Namespace, helmArgs); err != nil { + return err + } + + if cmd.Wait { + if err := waitForClusterInitialized(ctx, managementClient, clusterName); err != nil { + return err + } + } + + log.Infof("added cluster: cluster=%s", clusterName) + + return nil +} + +type clusterSetup struct { + managementClient kube.Interface + accessKey *managementv1.ClusterAccessKey + chartVersion string +} + +type createClusterParams struct { + clusterName string + user string + team string +} + +func (cmd *ClusterCmd) setupCluster( + ctx context.Context, + clusterName string, +) (clusterSetup, error) { + devsyConfig, err := config.LoadConfig(cmd.Context, "") + if err != nil { + return clusterSetup{}, err + } + + cmd.Host, err = ensureHost(devsyConfig, cmd.Host) + if err != nil { + return clusterSetup{}, err + } baseClient, err := platform.InitClientFromHost(ctx, devsyConfig, cmd.Host) if err != nil { - return err + return clusterSetup{}, err } managementClient, err := baseClient.Management() if err != nil { - return err + return clusterSetup{}, err } devsyVersion, err := baseClient.Version() if err != nil { - return fmt.Errorf("get pro version: %w", err) + return clusterSetup{}, fmt.Errorf("get pro version: %w", err) } user, team := getUserOrTeam(baseClient) - _, err = managementClient.Loft().ManagementV1().Clusters().Create(ctx, &managementv1.Cluster{ + if err := cmd.createClusterResource(ctx, managementClient, createClusterParams{ + clusterName: clusterName, + user: user, + team: team, + }); err != nil { + return clusterSetup{}, err + } + + accessKey, err := managementClient.Loft(). + ManagementV1(). + Clusters(). + GetAccessKey(ctx, clusterName, metav1.GetOptions{}) + if err != nil { + return clusterSetup{}, fmt.Errorf("get cluster access key: %w", err) + } + + return clusterSetup{ + managementClient: managementClient, + accessKey: accessKey, + chartVersion: devsyVersion.Version, + }, nil +} + +func (cmd *ClusterCmd) createClusterResource( + ctx context.Context, + managementClient kube.Interface, + params createClusterParams, +) error { + _, err := managementClient.Loft().ManagementV1().Clusters().Create(ctx, &managementv1.Cluster{ ObjectMeta: metav1.ObjectMeta{ - Name: clusterName, + Name: params.clusterName, }, Spec: managementv1.ClusterSpec{ ClusterSpec: storagev1.ClusterSpec{ DisplayName: cmd.DisplayName, Owner: &storagev1.UserOrTeam{ - User: user, - Team: team, + User: params.user, + Team: params.team, }, NetworkPeer: true, - Access: getAccess(user, team), + Access: getAccess(params.user, params.team), }, }, }, metav1.CreateOptions{}) @@ -174,19 +256,14 @@ func (cmd *ClusterCmd) Run(ctx context.Context, args []string) error { return fmt.Errorf("create cluster: %w", err) } - accessKey, err := managementClient.Loft(). - ManagementV1(). - Clusters(). - GetAccessKey(ctx, clusterName, metav1.GetOptions{}) - if err != nil { - return fmt.Errorf("get cluster access key: %w", err) - } - - namespace := cmd.Namespace + return nil +} - helmArgs := []string{ - "upgrade", "loft", - } +func (cmd *ClusterCmd) buildHelmArgs( + chartVersion string, + accessKey *managementv1.ClusterAccessKey, +) []string { + helmArgs := []string{"upgrade", "loft"} if os.Getenv("DEVELOPMENT") == "true" { helmArgs = []string{ @@ -196,7 +273,7 @@ func (cmd *ClusterCmd) Run(ctx context.Context, args []string) error { cmp.Or(os.Getenv("DEVELOPMENT_CHART_DIR"), "./chart"), "--create-namespace", "--namespace", - namespace, + cmd.Namespace, "--set", "agentOnly=true", "--set", @@ -206,30 +283,7 @@ func (cmd *ClusterCmd) Run(ctx context.Context, args []string) error { ), } } else { - if cmd.HelmChartPath != "" { - helmArgs = append(helmArgs, cmd.HelmChartPath) - } else { - helmArgs = append(helmArgs, "loft", "--repo", "https://charts.devsy.sh") - } - - if devsyVersion.Version != "" { - helmArgs = append(helmArgs, "--version", devsyVersion.Version) - } - - if cmd.HelmChartVersion != "" { - helmArgs = append(helmArgs, "--version", cmd.HelmChartVersion) - } - - // general arguments - helmArgs = append( - helmArgs, - "--install", - "--create-namespace", - "--namespace", - cmd.Namespace, - "--set", - "agentOnly=true", - ) + helmArgs = cmd.appendReleaseArgs(helmArgs, chartVersion) } for _, set := range cmd.HelmSet { @@ -239,39 +293,65 @@ func (cmd *ClusterCmd) Run(ctx context.Context, args []string) error { helmArgs = append(helmArgs, "--values", values) } - if accessKey.DevsyHost != "" { - helmArgs = append(helmArgs, "--set", "url="+accessKey.DevsyHost) - } + return cmd.appendAccessKeyArgs(helmArgs, accessKey) +} - if accessKey.AccessKey != "" { - helmArgs = append(helmArgs, "--set", "token="+accessKey.AccessKey) +func (cmd *ClusterCmd) appendReleaseArgs(helmArgs []string, chartVersion string) []string { + if cmd.HelmChartPath != "" { + helmArgs = append(helmArgs, cmd.HelmChartPath) + } else { + helmArgs = append(helmArgs, "loft", "--repo", "https://charts.devsy.sh") } - if cmd.Insecure || accessKey.Insecure { - helmArgs = append(helmArgs, "--set", "insecureSkipVerify=true") + if chartVersion != "" { + helmArgs = append(helmArgs, "--version", chartVersion) } - if accessKey.CaCert != "" { - helmArgs = append(helmArgs, "--set", "additionalCA="+accessKey.CaCert) + if cmd.HelmChartVersion != "" { + helmArgs = append(helmArgs, "--version", cmd.HelmChartVersion) } + return append( + helmArgs, + "--install", + "--create-namespace", + "--namespace", + cmd.Namespace, + "--set", + "agentOnly=true", + ) +} + +func (cmd *ClusterCmd) appendAccessKeyArgs( + helmArgs []string, + accessKey *managementv1.ClusterAccessKey, +) []string { + if accessKey.DevsyHost != "" { + helmArgs = append(helmArgs, "--set", "url="+accessKey.DevsyHost) + } + if cmd.Insecure || accessKey.Insecure { + helmArgs = append(helmArgs, "--set", "insecureSkipVerify=true") + } if cmd.Wait { helmArgs = append(helmArgs, "--wait") } - if cmd.KubeContext != "" { helmArgs = append(helmArgs, "--kube-context", cmd.KubeContext) } + return helmArgs +} + +func loadKubeClientset(kubeContext string) (*kubernetes.Clientset, error) { kubeClientConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( clientcmd.NewDefaultClientConfigLoadingRules(), &clientcmd.ConfigOverrides{}, ) - if cmd.KubeContext != "" { + if kubeContext != "" { kubeConfig, err := kubeClientConfig.RawConfig() if err != nil { - return fmt.Errorf( + return nil, fmt.Errorf( "there is an error loading your current kube config (%w), make sure you have access "+ "to a kubernetes cluster and the command `kubectl get namespaces` is working", err, @@ -280,7 +360,7 @@ func (cmd *ClusterCmd) Run(ctx context.Context, args []string) error { kubeClientConfig = clientcmd.NewNonInteractiveClientConfig( kubeConfig, - cmd.KubeContext, + kubeContext, &clientcmd.ConfigOverrides{}, clientcmd.NewDefaultClientConfigLoadingRules(), ) @@ -288,7 +368,7 @@ func (cmd *ClusterCmd) Run(ctx context.Context, args []string) error { config, err := kubeClientConfig.ClientConfig() if err != nil { - return fmt.Errorf( + return nil, fmt.Errorf( "there is an error loading your current kube config (%w), make sure you have access "+ "to a kubernetes cluster and the command `kubectl get namespaces` is working", err, @@ -297,9 +377,52 @@ func (cmd *ClusterCmd) Run(ctx context.Context, args []string) error { clientset, err := kubernetes.NewForConfig(config) if err != nil { - return fmt.Errorf("create kube client: %w", err) + return nil, fmt.Errorf("create kube client: %w", err) } + return clientset, nil +} + +func writeAgentSecretsFile(accessKey *managementv1.ClusterAccessKey) (string, error) { + secrets := map[string]string{} + if accessKey.AccessKey != "" { + secrets["token"] = accessKey.AccessKey + } + if accessKey.CaCert != "" { + secrets["additionalCA"] = accessKey.CaCert + } + if len(secrets) == 0 { + return "", nil + } + + data, err := yaml.Marshal(secrets) + if err != nil { + return "", fmt.Errorf("marshal agent secret values: %w", err) + } + + f, err := os.CreateTemp("", "devsy-agent-values-*.yaml") + if err != nil { + return "", fmt.Errorf("create agent secret values file: %w", err) + } + if _, err := f.Write(data); err != nil { + _ = f.Close() + _ = os.Remove(f.Name()) + return "", fmt.Errorf("write agent secret values file: %w", err) + } + if err := f.Close(); err != nil { + _ = os.Remove(f.Name()) + return "", fmt.Errorf("close agent secret values file: %w", err) + } + + return f.Name(), nil +} + +func installAgent( + ctx context.Context, + clientset *kubernetes.Clientset, + namespace string, + helmArgs []string, +) error { errChan := make(chan error) go func() { @@ -312,45 +435,48 @@ func (cmd *ClusterCmd) Run(ctx context.Context, args []string) error { log.Info("Installing agent") log.Debugf("Running helm command: %v", helmCmd.Args) - err = helmCmd.Run() - if err != nil { + if err := helmCmd.Run(); err != nil { errChan <- fmt.Errorf("failed to install chart: %w", err) } close(errChan) }() - _, err = platform.WaitForPodReady(ctx, clientset, namespace) + _, err := platform.WaitForPodReady(ctx, clientset, namespace) if err = errors.Join(err, <-errChan); err != nil { return fmt.Errorf("wait for pod: %w", err) } - if cmd.Wait { - log.Info("Waiting for the cluster to be initialized") - waitErr := wait.PollUntilContextTimeout( - ctx, - time.Second, - 5*time.Minute, - false, - func(ctx context.Context) (done bool, err error) { - clusterInstance, err := managementClient.Loft(). - ManagementV1(). - Clusters(). - Get(ctx, clusterName, metav1.GetOptions{}) - if err != nil && !kerrors.IsNotFound(err) { - return false, err - } - - return clusterInstance != nil && - clusterInstance.Status.Phase == storagev1.ClusterStatusPhaseInitialized, nil - }, - ) - if waitErr != nil { - return fmt.Errorf("get cluster: %w", waitErr) - } - } + return nil +} - log.Infof("added cluster: cluster=%s", clusterName) +func waitForClusterInitialized( + ctx context.Context, + managementClient kube.Interface, + clusterName string, +) error { + log.Info("Waiting for the cluster to be initialized") + waitErr := wait.PollUntilContextTimeout( + ctx, + time.Second, + 5*time.Minute, + false, + func(ctx context.Context) (done bool, err error) { + clusterInstance, err := managementClient.Loft(). + ManagementV1(). + Clusters(). + Get(ctx, clusterName, metav1.GetOptions{}) + if err != nil && !kerrors.IsNotFound(err) { + return false, err + } + + return clusterInstance != nil && + clusterInstance.Status.Phase == storagev1.ClusterStatusPhaseInitialized, nil + }, + ) + if waitErr != nil { + return fmt.Errorf("get cluster: %w", waitErr) + } return nil } diff --git a/cmd/pro/daemon/netcheck.go b/cmd/pro/daemon/netcheck.go index 1f791de9a..ee9e39dd6 100644 --- a/cmd/pro/daemon/netcheck.go +++ b/cmd/pro/daemon/netcheck.go @@ -16,6 +16,7 @@ import ( "github.com/devsy-org/devsy/pkg/table" "github.com/spf13/cobra" "tailscale.com/client/local" + "tailscale.com/ipn/ipnstate" ) // NetcheckCmd holds the Devsy daemon flags. @@ -100,21 +101,28 @@ func (cmd *NetcheckCmd) Run( return err } regionLabel := fmt.Sprintf("DERP %d (%s)", region.RegionID, region.RegionCode) - for _, e := range report.Errors { - rows = append(rows, []string{regionLabel, "Error", e}) - } - for _, w := range report.Warnings { - rows = append(rows, []string{regionLabel, "Warning", w}) - } - for _, i := range report.Info { - rows = append(rows, []string{regionLabel, "Info", i}) - } - if len(report.Errors) == 0 && len(report.Warnings) == 0 && len(report.Info) == 0 { - rows = append(rows, []string{regionLabel, "", ""}) - } + rows = append(rows, derpRegionRows(report, regionLabel)...) } table.Print([]string{"Region", "Level", "Message"}, rows) return nil } + +func derpRegionRows(report *ipnstate.DebugDERPRegionReport, regionLabel string) [][]string { + rows := [][]string{} + for _, e := range report.Errors { + rows = append(rows, []string{regionLabel, "Error", e}) + } + for _, w := range report.Warnings { + rows = append(rows, []string{regionLabel, "Warning", w}) + } + for _, i := range report.Info { + rows = append(rows, []string{regionLabel, "Info", i}) + } + if len(report.Errors) == 0 && len(report.Warnings) == 0 && len(report.Info) == 0 { + rows = append(rows, []string{regionLabel, "", ""}) + } + + return rows +} diff --git a/cmd/pro/list.go b/cmd/pro/list.go index ad3bad0a5..201e61d05 100644 --- a/cmd/pro/list.go +++ b/cmd/pro/list.go @@ -72,60 +72,78 @@ func (cmd *ListCmd) Run(ctx context.Context) error { } switch mode { case output.ModePlain: - tableEntries := [][]string{} - for _, proInstance := range proInstances { - entry := []string{ - proInstance.Host, - proInstance.Provider, - time.Since(proInstance.CreationTimestamp.Time).Round(1 * time.Second).String(), - } - if cmd.Login { - err = checkLogin(ctx, devsyConfig, proInstance) - entry = append(entry, fmt.Sprintf("%t", err == nil)) - } - - tableEntries = append(tableEntries, entry) - } - sort.SliceStable(tableEntries, func(i, j int) bool { - return tableEntries[i][0] < tableEntries[j][0] - }) - - tableHeaders := []string{ - "Host", - "Provider", - "Age", + cmd.printPlain(ctx, devsyConfig, proInstances) + case output.ModeJSON: + return cmd.printJSON(ctx, devsyConfig, proInstances) + } + + return nil +} + +func (cmd *ListCmd) printPlain( + ctx context.Context, + devsyConfig *config.Config, + proInstances []*provider.ProInstance, +) { + tableEntries := [][]string{} + for _, proInstance := range proInstances { + entry := []string{ + proInstance.Host, + proInstance.Provider, + time.Since(proInstance.CreationTimestamp.Time).Round(1 * time.Second).String(), } if cmd.Login { - tableHeaders = append(tableHeaders, "Authenticated") + err := checkLogin(ctx, devsyConfig, proInstance) + entry = append(entry, fmt.Sprintf("%t", err == nil)) } - table.Print(tableHeaders, tableEntries) - case output.ModeJSON: - tableEntries := []*proTableEntry{} - for _, proInstance := range proInstances { - entry := &proTableEntry{ - ProInstance: proInstance, - Context: devsyConfig.DefaultContext, - Capabilities: getCapabilities(devsyConfig, proInstance), - } - if cmd.Login { - err = checkLogin(ctx, devsyConfig, proInstance) - isAuthenticated := err == nil - entry.Authenticated = &isAuthenticated - } - - tableEntries = append(tableEntries, entry) - } + tableEntries = append(tableEntries, entry) + } + sort.SliceStable(tableEntries, func(i, j int) bool { + return tableEntries[i][0] < tableEntries[j][0] + }) + + tableHeaders := []string{ + "Host", + "Provider", + "Age", + } + if cmd.Login { + tableHeaders = append(tableHeaders, "Authenticated") + } + + table.Print(tableHeaders, tableEntries) +} - sort.SliceStable(tableEntries, func(i, j int) bool { - return tableEntries[i].Host < tableEntries[j].Host - }) - out, err := json.Marshal(tableEntries) - if err != nil { - return err +func (cmd *ListCmd) printJSON( + ctx context.Context, + devsyConfig *config.Config, + proInstances []*provider.ProInstance, +) error { + tableEntries := []*proTableEntry{} + for _, proInstance := range proInstances { + entry := &proTableEntry{ + ProInstance: proInstance, + Context: devsyConfig.DefaultContext, + Capabilities: getCapabilities(devsyConfig, proInstance), } - fmt.Print(string(out)) + if cmd.Login { + err := checkLogin(ctx, devsyConfig, proInstance) + isAuthenticated := err == nil + entry.Authenticated = &isAuthenticated + } + + tableEntries = append(tableEntries, entry) + } + + sort.SliceStable(tableEntries, func(i, j int) bool { + return tableEntries[i].Host < tableEntries[j].Host + }) + out, err := json.Marshal(tableEntries) + if err != nil { + return err } + fmt.Print(string(out)) //nolint:forbidigo // CLI stdout output return nil } diff --git a/cmd/pro/login.go b/cmd/pro/login.go index ff316ea96..b176d518c 100644 --- a/cmd/pro/login.go +++ b/cmd/pro/login.go @@ -378,19 +378,33 @@ func login( if err := ctx.Err(); err != nil { return err } - if accessKey != "" && !forceBrowser { - err = loader.LoginWithAccessKey(url, accessKey, true, true) - } else { - if skipBrowserLogin { - return fmt.Errorf("unable to login to loft host") - } - err = loader.Login(url, true) + + return performLogin(loginParams{ + loader: loader, + url: url, + accessKey: accessKey, + skipBrowserLogin: skipBrowserLogin, + forceBrowser: forceBrowser, + }) +} + +type loginParams struct { + loader client.Client + url string + accessKey string + skipBrowserLogin bool + forceBrowser bool +} + +func performLogin(params loginParams) error { + if params.accessKey != "" && !params.forceBrowser { + return params.loader.LoginWithAccessKey(params.url, params.accessKey, true, true) } - if err != nil { - return err + if params.skipBrowserLogin { + return fmt.Errorf("unable to login to loft host") } - return nil + return params.loader.Login(params.url, true) } var fallbackProvider = `name: devsy-pro diff --git a/cmd/pro/provider/create/workspace.go b/cmd/pro/provider/create/workspace.go index 10575700c..8a1ed090e 100644 --- a/cmd/pro/provider/create/workspace.go +++ b/cmd/pro/provider/create/workspace.go @@ -55,47 +55,79 @@ func (cmd *WorkspaceCmd) Run( } // fully serialized instance, right now only used by GUI - instanceEnv := os.Getenv(platform.WorkspaceInstanceEnv) - if instanceEnv != "" { - instance := &managementv1.DevsyWorkspaceInstance{} // init pointer - err := json.Unmarshal([]byte(instanceEnv), instance) - if err != nil { - return fmt.Errorf("unmarshal workpace instance %s: %w", instanceEnv, err) - } - - updatedInstance, err := createInstance(ctx, baseClient, instance) - if err != nil { - return err - } - - out, err := json.Marshal(updatedInstance) - if err != nil { - return err - } - - fmt.Println(string(out)) - return nil + if instanceEnv := os.Getenv(platform.WorkspaceInstanceEnv); instanceEnv != "" { + return createFromInstanceEnv(ctx, baseClient, instanceEnv) } // Info through env, right now only used by CLI - workspaceID := os.Getenv(config.EnvProviderWorkspaceID) - workspaceUID := os.Getenv(config.EnvProviderWorkspaceUID) - workspaceFolder := os.Getenv(config.EnvProviderWorkspaceFolder) - workspaceContext := os.Getenv(config.EnvProviderWorkspaceContext) - workspacePicture := os.Getenv(platform.WorkspacePictureEnv) - workspaceSource := os.Getenv(platform.WorkspaceSourceEnv) - if workspaceUID == "" || workspaceID == "" || workspaceFolder == "" { - return fmt.Errorf( + return createFromEnv(ctx, baseClient) +} + +func createFromInstanceEnv( + ctx context.Context, + baseClient client.Client, + instanceEnv string, +) error { + instance := &managementv1.DevsyWorkspaceInstance{} // init pointer + err := json.Unmarshal([]byte(instanceEnv), instance) + if err != nil { + return fmt.Errorf("unmarshal workspace instance %s: %w", instanceEnv, err) + } + + updatedInstance, err := createInstance(ctx, baseClient, instance) + if err != nil { + return err + } + + out, err := json.Marshal(updatedInstance) + if err != nil { + return err + } + + fmt.Println(string(out)) //nolint:forbidigo // CLI stdout output + return nil +} + +type workspaceEnv struct { + id string + uid string + folder string + context string + picture string + source string +} + +func readWorkspaceEnv() (workspaceEnv, error) { + env := workspaceEnv{ + id: os.Getenv(config.EnvProviderWorkspaceID), + uid: os.Getenv(config.EnvProviderWorkspaceUID), + folder: os.Getenv(config.EnvProviderWorkspaceFolder), + context: os.Getenv(config.EnvProviderWorkspaceContext), + picture: os.Getenv(platform.WorkspacePictureEnv), + source: os.Getenv(platform.WorkspaceSourceEnv), + } + if env.uid == "" || env.id == "" || env.folder == "" { + return env, fmt.Errorf( "workspaceID, workspaceUID or workspace folder not found: %s, %s, %s", - workspaceID, - workspaceUID, - workspaceFolder, + env.id, + env.uid, + env.folder, ) } + + return env, nil +} + +func createFromEnv(ctx context.Context, baseClient client.Client) error { + env, err := readWorkspaceEnv() + if err != nil { + return err + } + instance, err := platform.FindInstance( ctx, baseClient, - platform.FindInstanceOptions{UID: workspaceUID}, + platform.FindInstanceOptions{UID: env.uid}, ) if err != nil { return err @@ -111,10 +143,10 @@ func (cmd *WorkspaceCmd) Run( instance, err = form.CreateInstance( ctx, baseClient, - workspaceID, - workspaceUID, - workspaceSource, - workspacePicture, + env.id, + env.uid, + env.source, + env.picture, ) if err != nil { return err @@ -125,6 +157,13 @@ func (cmd *WorkspaceCmd) Run( return err } + return saveImportedWorkspaceConfig(instance, env.context, env.id) +} + +func saveImportedWorkspaceConfig( + instance *managementv1.DevsyWorkspaceInstance, + workspaceContext, workspaceID string, +) error { // once we have the instance, update workspace and save config // TODO: Do we need a file lock? workspaceConfig, err := provider.LoadWorkspaceConfig(workspaceContext, workspaceID) diff --git a/cmd/pro/provider/list/templates.go b/cmd/pro/provider/list/templates.go index babb5a1ab..03c94a35c 100644 --- a/cmd/pro/provider/list/templates.go +++ b/cmd/pro/provider/list/templates.go @@ -208,24 +208,12 @@ func GetLatestMatchedVersion( } // does the version match our restrictions? - if (splittedVersion[0] == "x" || splittedVersion[0] == "X" || strconv.FormatUint(parsedVersion.Major, 10) == splittedVersion[0]) && - (splittedVersion[1] == "x" || splittedVersion[1] == "X" || strconv.FormatUint(parsedVersion.Minor, 10) == splittedVersion[1]) && - (splittedVersion[2] == "x" || splittedVersion[2] == "X" || strconv.FormatUint(parsedVersion.Patch, 10) == splittedVersion[2]) { - if latestMatchedVersionObj == nil || latestMatchedVersionObj.Version.LT(parsedVersion) { - latestMatchedVersionObj = &matchedVersion{ - Object: version, - Version: parsedVersion, - } - } + if versionMatchesPattern(parsedVersion, splittedVersion) { + latestMatchedVersionObj = maxVersion(latestMatchedVersionObj, version, parsedVersion) } // latest available version - if latestVersionObj == nil || latestVersionObj.Version.LT(parsedVersion) { - latestVersionObj = &matchedVersion{ - Object: version, - Version: parsedVersion, - } - } + latestVersionObj = maxVersion(latestVersionObj, version, parsedVersion) } if latestVersionObj != nil { @@ -238,6 +226,31 @@ func GetLatestMatchedVersion( return latestVersion, latestMatchedVersion, nil } +func versionMatchesPattern(version semver.Version, pattern []string) bool { + return versionSegmentMatches(pattern[0], version.Major) && + versionSegmentMatches(pattern[1], version.Minor) && + versionSegmentMatches(pattern[2], version.Patch) +} + +func versionSegmentMatches(segment string, value uint64) bool { + return segment == "x" || segment == "X" || strconv.FormatUint(value, 10) == segment +} + +func maxVersion( + best *matchedVersion, + candidate storagev1.VersionAccessor, + parsed semver.Version, +) *matchedVersion { + if best == nil || best.Version.LT(parsed) { + return &matchedVersion{ + Object: candidate, + Version: parsed, + } + } + + return best +} + var replaceRegEx = regexp.MustCompile("[^a-zA-Z0-9]+") func VariableToEnvironmentVariable(variable string) string { diff --git a/cmd/pro/provider/list/workspaces.go b/cmd/pro/provider/list/workspaces.go index f47df9a5d..ff401478d 100644 --- a/cmd/pro/provider/list/workspaces.go +++ b/cmd/pro/provider/list/workspaces.go @@ -12,6 +12,7 @@ import ( "github.com/devsy-org/devsy/pkg/log" "github.com/devsy-org/devsy/pkg/platform" "github.com/devsy-org/devsy/pkg/platform/client" + "github.com/devsy-org/devsy/pkg/platform/kube" "github.com/devsy-org/devsy/pkg/platform/project" "github.com/spf13/cobra" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -65,29 +66,15 @@ func (cmd *WorkspacesCmd) Run(ctx context.Context) error { filterByOwner := os.Getenv(config.EnvLoftFilterByOwner) == config.BoolTrue workspaces := []*managementv1.DevsyWorkspaceInstance{} for _, p := range projectList.Items { - ns := project.ProjectNamespace(p.GetName()) - workspaceList, err := managementClient.Loft(). - ManagementV1(). - DevsyWorkspaceInstances(ns). - List(ctx, metav1.ListOptions{}) - if err != nil { - log.Infof("list workspaces in project %q: %v", p.GetName(), err) - continue - } - - for _, instance := range workspaceList.Items { - instance := &instance - if filterByOwner && !platform.IsOwner(baseClient.Self(), instance.GetOwner()) { - continue - } - - if instance.GetLabels() == nil { - instance.Labels = map[string]string{} - } - instance.Labels[config.K8sProjectLabel] = p.GetName() - - workspaces = append(workspaces, instance) - } + workspaces = append( + workspaces, + projectWorkspaces(ctx, projectWorkspacesParams{ + managementClient: managementClient, + baseClient: baseClient, + projectName: p.GetName(), + filterByOwner: filterByOwner, + })..., + ) } wBytes, err := json.Marshal(workspaces) @@ -98,3 +85,43 @@ func (cmd *WorkspacesCmd) Run(ctx context.Context) error { return nil } + +type projectWorkspacesParams struct { + managementClient kube.Interface + baseClient client.Client + projectName string + filterByOwner bool +} + +func projectWorkspaces( + ctx context.Context, + params projectWorkspacesParams, +) []*managementv1.DevsyWorkspaceInstance { + ns := project.ProjectNamespace(params.projectName) + workspaceList, err := params.managementClient.Loft(). + ManagementV1(). + DevsyWorkspaceInstances(ns). + List(ctx, metav1.ListOptions{}) + if err != nil { + log.Infof("list workspaces in project %q: %v", params.projectName, err) + return nil + } + + workspaces := []*managementv1.DevsyWorkspaceInstance{} + for _, instance := range workspaceList.Items { + instance := &instance + if params.filterByOwner && + !platform.IsOwner(params.baseClient.Self(), instance.GetOwner()) { + continue + } + + if instance.GetLabels() == nil { + instance.Labels = map[string]string{} + } + instance.Labels[config.K8sProjectLabel] = params.projectName + + workspaces = append(workspaces, instance) + } + + return workspaces +} diff --git a/cmd/pro/provider/update/workspace.go b/cmd/pro/provider/update/workspace.go index 147be5074..8d7e0495b 100644 --- a/cmd/pro/provider/update/workspace.go +++ b/cmd/pro/provider/update/workspace.go @@ -53,60 +53,89 @@ func (cmd *WorkspaceCmd) Run( } // GUI - instanceEnv := os.Getenv(platform.WorkspaceInstanceEnv) - if instanceEnv != "" { - newInstance := &managementv1.DevsyWorkspaceInstance{} - err := json.Unmarshal([]byte(instanceEnv), newInstance) - if err != nil { - return fmt.Errorf("unmarshal workspace instance %s: %w", instanceEnv, err) - } - newInstance.TypeMeta = metav1.TypeMeta{} // ignore - - projectName := project.ProjectFromNamespace(newInstance.GetNamespace()) - opts := platform.FindInstanceOptions{Name: newInstance.GetName(), ProjectName: projectName} - oldInstance, err := platform.FindInstance(ctx, baseClient, opts) - if err != nil { - return err - } - if oldInstance == nil { - return fmt.Errorf( - "workspace instance %q not found in project %q", - newInstance.GetName(), - projectName, - ) - } - - updatedInstance, err := updateInstance(ctx, baseClient, oldInstance, newInstance) - if err != nil { - return err - } - - out, err := json.Marshal(updatedInstance) - if err != nil { - return err - } - fmt.Println(string(out)) - - return nil + if instanceEnv := os.Getenv(platform.WorkspaceInstanceEnv); instanceEnv != "" { + return updateFromInstanceEnv(ctx, baseClient, instanceEnv) } // CLI - if !terminal.IsTerminalIn { - return fmt.Errorf("unable to update instance through CLI if stdin is not a terminal") + return updateFromEnv(ctx, baseClient) +} + +func updateFromInstanceEnv( + ctx context.Context, + baseClient client.Client, + instanceEnv string, +) error { + newInstance := &managementv1.DevsyWorkspaceInstance{} + err := json.Unmarshal([]byte(instanceEnv), newInstance) + if err != nil { + return fmt.Errorf("unmarshal workspace instance %s: %w", instanceEnv, err) + } + newInstance.TypeMeta = metav1.TypeMeta{} // ignore + + projectName := project.ProjectFromNamespace(newInstance.GetNamespace()) + opts := platform.FindInstanceOptions{Name: newInstance.GetName(), ProjectName: projectName} + oldInstance, err := platform.FindInstance(ctx, baseClient, opts) + if err != nil { + return err } - workspaceID := os.Getenv(platform.WorkspaceIDEnv) - workspaceUID := os.Getenv(platform.WorkspaceUIDEnv) - project := os.Getenv(platform.ProjectEnv) - if workspaceUID == "" || workspaceID == "" || project == "" { + if oldInstance == nil { return fmt.Errorf( + "workspace instance %q not found in project %q", + newInstance.GetName(), + projectName, + ) + } + + updatedInstance, err := updateInstance(ctx, baseClient, oldInstance, newInstance) + if err != nil { + return err + } + + out, err := json.Marshal(updatedInstance) + if err != nil { + return err + } + fmt.Println(string(out)) //nolint:forbidigo // CLI stdout output + + return nil +} + +type updateEnv struct { + workspaceID string + workspaceUID string + projectName string +} + +func readUpdateEnv() (updateEnv, error) { + env := updateEnv{ + workspaceID: os.Getenv(platform.WorkspaceIDEnv), + workspaceUID: os.Getenv(platform.WorkspaceUIDEnv), + projectName: os.Getenv(platform.ProjectEnv), + } + if env.workspaceUID == "" || env.workspaceID == "" || env.projectName == "" { + return env, fmt.Errorf( "workspaceID, workspaceUID or project not found: %s, %s, %s", - workspaceID, - workspaceUID, - project, + env.workspaceID, + env.workspaceUID, + env.projectName, ) } - opts := platform.FindInstanceOptions{UID: workspaceUID, ProjectName: project} + return env, nil +} + +func updateFromEnv(ctx context.Context, baseClient client.Client) error { + if !terminal.IsTerminalIn { + return fmt.Errorf("unable to update instance through CLI if stdin is not a terminal") + } + + env, err := readUpdateEnv() + if err != nil { + return err + } + + opts := platform.FindInstanceOptions{UID: env.workspaceUID, ProjectName: env.projectName} oldInstance, err := platform.FindInstance(ctx, baseClient, opts) if err != nil { return err @@ -114,8 +143,8 @@ func (cmd *WorkspaceCmd) Run( if oldInstance == nil { return fmt.Errorf( "workspace instance with UID %q not found in project %q", - workspaceUID, - project, + env.workspaceUID, + env.projectName, ) } diff --git a/cmd/pro/provider/watch/workspaces.go b/cmd/pro/provider/watch/workspaces.go index eddfbda62..f97185f73 100644 --- a/cmd/pro/provider/watch/workspaces.go +++ b/cmd/pro/provider/watch/workspaces.go @@ -103,7 +103,32 @@ func (cmd *WorkspacesCmd) Run( filterByOwner := os.Getenv(config.EnvLoftFilterByOwner) == config.BoolTrue instanceStore := newStore(workspaceInformer, self, cmd.Context, filterByOwner) - _, err = workspaceInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ + _, err = workspaceInformer.Informer(). + AddEventHandler(workspaceEventHandler(stdout, instanceStore)) + if err != nil { + return err + } + + stopCh := make(chan struct{}) + defer close(stopCh) + go func() { + factory.Start(stopCh) + factory.WaitForCacheSync(stopCh) + + // Kick off initial message + printInstances(stdout, instanceStore.List()) + }() + + <-stopCh + + return nil +} + +func workspaceEventHandler( + stdout io.Writer, + instanceStore *instanceStore, +) cache.ResourceEventHandlerFuncs { + return cache.ResourceEventHandlerFuncs{ AddFunc: func(obj any) { instance, ok := obj.(*managementv1.DevsyWorkspaceInstance) if !ok { @@ -140,24 +165,7 @@ func (cmd *WorkspacesCmd) Run( instanceStore.Delete(instance) printInstances(stdout, instanceStore.List()) }, - }) - if err != nil { - return err } - - stopCh := make(chan struct{}) - defer close(stopCh) - go func() { - factory.Start(stopCh) - factory.WaitForCacheSync(stopCh) - - // Kick off initial message - printInstances(stdout, instanceStore.List()) - }() - - <-stopCh - - return nil } type instanceStore struct { @@ -193,33 +201,8 @@ func (s *instanceStore) Add(instance *managementv1.DevsyWorkspaceInstance) { if s.filterByOwner && !platform.IsOwner(s.self, instance.Spec.Owner) { return } - var source *provider.WorkspaceSource - if instance.GetAnnotations() != nil && - instance.GetAnnotations()[storagev1.DevsyWorkspaceSourceAnnotation] != "" { - source = provider.ParseWorkspaceSource( - instance.GetAnnotations()[storagev1.DevsyWorkspaceSourceAnnotation], - ) - } - var ideConfig *provider.WorkspaceIDEConfig - if instance.GetLabels() != nil && instance.GetLabels()[storagev1.DevsyWorkspaceIDLabel] != "" { - id := instance.GetLabels()[storagev1.DevsyWorkspaceIDLabel] - workspaceConfig, err := provider.LoadWorkspaceConfig(s.context, id) - if err == nil { - ideConfig = &workspaceConfig.IDE - } - } - - proInstance := &ProWorkspaceInstance{ - TypeMeta: instance.TypeMeta, - ObjectMeta: instance.ObjectMeta, - Spec: instance.Spec, - Status: ProWorkspaceInstanceStatus{ - DevsyWorkspaceInstanceStatus: instance.Status, - Source: source, - IDE: ideConfig, - }, - } + proInstance := s.buildProInstance(instance) key := s.key(instance.ObjectMeta) s.m.Lock() @@ -255,61 +238,7 @@ func (s *instanceStore) List() []*ProWorkspaceInstance { ) if err == nil { for _, workspace := range localWorkspaces { - if workspace.Imported && workspace.Pro != nil { - // get instance for imported workspace - selector, err := metav1.LabelSelectorAsSelector(&metav1.LabelSelector{ - MatchLabels: map[string]string{ - storagev1.DevsyWorkspaceUIDLabel: workspace.UID, - }, - }) - if err != nil { - continue - } - - l, err := s.informer.Lister(). - DevsyWorkspaceInstances(project.ProjectFromNamespace(workspace.Pro.Project)). - List(selector) - if err != nil { - continue - } - if len(l) == 0 { - continue - } - instance := l[0] - s.m.Lock() - if _, ok := s.instances[s.key(instance.ObjectMeta)]; ok { - continue - } - s.m.Unlock() - - var source *provider.WorkspaceSource - if instance.GetAnnotations() != nil && - instance.GetAnnotations()[storagev1.DevsyWorkspaceSourceAnnotation] != "" { - source = provider.ParseWorkspaceSource( - instance.GetAnnotations()[storagev1.DevsyWorkspaceSourceAnnotation], - ) - } - - var ideConfig *provider.WorkspaceIDEConfig - if instance.GetLabels() != nil && - instance.GetLabels()[storagev1.DevsyWorkspaceIDLabel] != "" { - id := instance.GetLabels()[storagev1.DevsyWorkspaceIDLabel] - workspaceConfig, err := provider.LoadWorkspaceConfig(s.context, id) - if err == nil { - ideConfig = &workspaceConfig.IDE - } - } - - proInstance := &ProWorkspaceInstance{ - TypeMeta: instance.TypeMeta, - ObjectMeta: instance.ObjectMeta, - Spec: instance.Spec, - Status: ProWorkspaceInstanceStatus{ - DevsyWorkspaceInstanceStatus: instance.Status, - Source: source, - IDE: ideConfig, - }, - } + if proInstance := s.importedProInstance(workspace); proInstance != nil { instanceList = append(instanceList, proInstance) } } @@ -324,6 +253,75 @@ func (s *instanceStore) List() []*ProWorkspaceInstance { return instanceList } +func (s *instanceStore) buildProInstance( + instance *managementv1.DevsyWorkspaceInstance, +) *ProWorkspaceInstance { + var source *provider.WorkspaceSource + if instance.GetAnnotations() != nil && + instance.GetAnnotations()[storagev1.DevsyWorkspaceSourceAnnotation] != "" { + source = provider.ParseWorkspaceSource( + instance.GetAnnotations()[storagev1.DevsyWorkspaceSourceAnnotation], + ) + } + + var ideConfig *provider.WorkspaceIDEConfig + if instance.GetLabels() != nil && instance.GetLabels()[storagev1.DevsyWorkspaceIDLabel] != "" { + id := instance.GetLabels()[storagev1.DevsyWorkspaceIDLabel] + workspaceConfig, err := provider.LoadWorkspaceConfig(s.context, id) + if err == nil { + ideConfig = &workspaceConfig.IDE + } + } + + return &ProWorkspaceInstance{ + TypeMeta: instance.TypeMeta, + ObjectMeta: instance.ObjectMeta, + Spec: instance.Spec, + Status: ProWorkspaceInstanceStatus{ + DevsyWorkspaceInstanceStatus: instance.Status, + Source: source, + IDE: ideConfig, + }, + } +} + +func (s *instanceStore) importedProInstance( + workspace *provider.Workspace, +) *ProWorkspaceInstance { + if !workspace.Imported || workspace.Pro == nil { + return nil + } + + // get instance for imported workspace + selector, err := metav1.LabelSelectorAsSelector(&metav1.LabelSelector{ + MatchLabels: map[string]string{ + storagev1.DevsyWorkspaceUIDLabel: workspace.UID, + }, + }) + if err != nil { + return nil + } + + l, err := s.informer.Lister(). + DevsyWorkspaceInstances(project.ProjectFromNamespace(workspace.Pro.Project)). + List(selector) + if err != nil { + return nil + } + if len(l) == 0 { + return nil + } + instance := l[0] + s.m.Lock() + if _, ok := s.instances[s.key(instance.ObjectMeta)]; ok { + s.m.Unlock() + return nil + } + s.m.Unlock() + + return s.buildProInstance(instance) +} + func printInstances(w io.Writer, instances []*ProWorkspaceInstance) { out, err := json.Marshal(instances) if err != nil { diff --git a/cmd/pro/start.go b/cmd/pro/start.go index 2890a4163..8547cde53 100644 --- a/cmd/pro/start.go +++ b/cmd/pro/start.go @@ -53,6 +53,10 @@ const ( LoftRouterDomainSecret = "loft-router-domain" // #nosec G101 passwordChangedHint = "(has been changed)" defaultUser = "admin" + + ingressNginx = "ingress-nginx" + helmRepositoryConfig = "--repository-config=''" + trueString = "true" ) var defaultReleaseName = config.ProReleaseName @@ -212,13 +216,40 @@ func (cmd *StartCmd) Run(ctx context.Context) error { cmd.LocalPort = "9898" } - err := cmd.prepare(ctx) + if err := cmd.prepare(ctx); err != nil { + return err + } + + isInstalled, err := cmd.prepareKubernetes(ctx) if err != nil { return err } + + // Upgrade Devsy if already installed + if isInstalled { + return cmd.handleAlreadyExistingInstallation(ctx) + } + + // Install Devsy + log.Info("Welcome to Devsy Pro!") + log.Info("This installer will help you to get started.") + + // make sure we are ready for installing + if err := cmd.prepareInstall(ctx); err != nil { + return err + } + + if err := cmd.upgrade(ctx); err != nil { + return err + } + + return cmd.success(ctx) +} + +func (cmd *StartCmd) prepareKubernetes(ctx context.Context) (bool, error) { // Uninstall already existing instance if cmd.Reset { - err = uninstall( + err := uninstall( ctx, cmd.KubeClient, cmd.RestConfig, @@ -226,47 +257,27 @@ func (cmd *StartCmd) Run(ctx context.Context) error { cmd.Namespace, ) if err != nil { - return err + return false, err } } // Is already installed? isInstalled, err := isAlreadyInstalled(ctx, cmd.KubeClient, cmd.Namespace) if err != nil { - return err + return false, err } // Use default password if none is set if cmd.Password == "" { defaultPassword, err := getDefaultPassword(ctx, cmd.KubeClient, cmd.Namespace) if err != nil { - return err + return false, err } cmd.Password = defaultPassword } - // Upgrade Devsy if already installed - if isInstalled { - return cmd.handleAlreadyExistingInstallation(ctx) - } - - // Install Devsy - log.Info("Welcome to Devsy Pro!") - log.Info("This installer will help you to get started.") - - // make sure we are ready for installing - err = cmd.prepareInstall(ctx) - if err != nil { - return err - } - - err = cmd.upgrade(ctx) - if err != nil { - return err - } - - return cmd.success(ctx) + return isInstalled, nil } func (cmd *StartCmd) appendHostArgs(extraArgs []string) []string { @@ -460,20 +471,8 @@ func (cmd *StartCmd) success(ctx context.Context) error { } // check if installed locally - isLocal := isInstalledLocally(ctx, cmd.KubeClient, cmd.Namespace) - if isLocal { - // check if loft domain secret is there - if !cmd.NoTunnel { - loftRouterDomain, err := cmd.pingLoftRouter(ctx, loftPod) - if err != nil { - log.Errorf("Error retrieving loft router domain: %v", err) - log.Info("Fallback to use port-forwarding") - } else if loftRouterDomain != "" { - return cmd.successLoftRouter(loftRouterDomain) - } - } - - return cmd.successLocal() + if isInstalledLocally(ctx, cmd.KubeClient, cmd.Namespace) { + return cmd.successLocalOrRouter(ctx, loftPod) } // get login link @@ -483,32 +482,59 @@ func (cmd *StartCmd) success(ctx context.Context) error { return err } - // check if loft is reachable - reachable, err := isHostReachable(ctx, host) - if !reachable || err != nil { - const ( - YesOption = "Yes" - NoOption = "No, re-run the DNS check" - ) + usePortForward, err := cmd.confirmPortForwardIfUnreachable(ctx, host) + if err != nil { + return err + } + if usePortForward { + return cmd.successLocal() + } - answer, err := log.QuestionDefault(&survey.QuestionOptions{ - Question: "Unable to reach Devsy at https://" + host + ". Do you want to start port-forwarding instead?", - DefaultValue: YesOption, - Options: []string{ - YesOption, - NoOption, - }, - }) + return cmd.successRemote(ctx, host) +} + +func (cmd *StartCmd) successLocalOrRouter(ctx context.Context, loftPod *corev1.Pod) error { + // check if loft domain secret is there + if !cmd.NoTunnel { + loftRouterDomain, err := cmd.pingLoftRouter(ctx, loftPod) if err != nil { - return err + log.Errorf("Error retrieving loft router domain: %v", err) + log.Info("Fallback to use port-forwarding") + } else if loftRouterDomain != "" { + return cmd.successLoftRouter(loftRouterDomain) } + } - if answer == YesOption { - return cmd.successLocal() - } + return cmd.successLocal() +} + +func (cmd *StartCmd) confirmPortForwardIfUnreachable( + ctx context.Context, + host string, +) (bool, error) { + reachable, err := isHostReachable(ctx, host) + if reachable && err == nil { + return false, nil } - return cmd.successRemote(ctx, host) + const ( + YesOption = "Yes" + NoOption = "No, re-run the DNS check" + ) + + answer, err := log.QuestionDefault(&survey.QuestionOptions{ + Question: "Unable to reach Devsy at https://" + host + ". Do you want to start port-forwarding instead?", + DefaultValue: YesOption, + Options: []string{ + YesOption, + NoOption, + }, + }) + if err != nil { + return false, err + } + + return answer == YesOption, nil } func (cmd *StartCmd) successRemote(ctx context.Context, host string) error { @@ -652,14 +678,9 @@ func (cmd *StartCmd) startDocker(ctx context.Context) error { } // check if container is there - if containerID != "" && (cmd.Reset || cmd.Upgrade) { - log.Info("Existing instance found.") - err = cmd.uninstallDocker(ctx, containerID) - if err != nil { - return err - } - - containerID = "" + containerID, err = cmd.resetExistingContainer(ctx, containerID) + if err != nil { + return err } // Use default password if none is set @@ -692,6 +713,22 @@ func (cmd *StartCmd) startDocker(ctx context.Context) error { return cmd.successDocker(ctx, containerID) } +func (cmd *StartCmd) resetExistingContainer( + ctx context.Context, + containerID string, +) (string, error) { + if containerID != "" && (cmd.Reset || cmd.Upgrade) { + log.Info("Existing instance found.") + if err := cmd.uninstallDocker(ctx, containerID); err != nil { + return "", err + } + + return "", nil + } + + return containerID, nil +} + func (cmd *StartCmd) successDocker(ctx context.Context, containerID string) error { if cmd.NoWait { return nil @@ -952,9 +989,17 @@ func (cmd *StartCmd) findLoftContainer( return "", nil } + return cmd.resolveRunningContainer(ctx, arr, onlyRunning) +} + +func (cmd *StartCmd) resolveRunningContainer( + ctx context.Context, + containerIDs []string, + onlyRunning bool, +) (string, error) { // remove the failed / exited containers runningContainerID := "" - for _, containerID := range arr { + for _, containerID := range containerIDs { containerState, err := cmd.inspectContainer(ctx, containerID) switch { case err != nil: @@ -1124,94 +1169,131 @@ func (cmd *StartCmd) handleAlreadyExistingInstallation(ctx context.Context) erro // Only ask if ingress should be enabled if --upgrade flag is not provided if !cmd.Upgrade && term.IsTerminal(os.Stdin) { - log.Info("Existing instance found.") - - // Check if Devsy is installed in a local cluster - isLocal := isInstalledLocally(ctx, cmd.KubeClient, cmd.Namespace) + var err error + enableIngress, err = cmd.promptEnableIngress(ctx) + if err != nil { + return err + } + } - // Skip question if --host flag is provided - if cmd.Host != "" { - enableIngress = true + // Only upgrade if --upgrade flag is present or user decided to enable ingress + if cmd.Upgrade || enableIngress { + err := cmd.upgrade(ctx) + if err != nil { + return err } + } - if enableIngress { - if isLocal { - // Confirm with user if this is a local cluster - const ( - YesOption = "Yes" - NoOption = "No, my cluster is running not locally (GKE, EKS, Bare Metal, etc.)" - ) + return cmd.success(ctx) +} - answer, err := log.QuestionDefault(&survey.QuestionOptions{ - Question: "Seems like your cluster is running locally (docker desktop, minikube, kind etc.). Is that correct?", - DefaultValue: YesOption, - Options: []string{ - YesOption, - NoOption, - }, - }) - if err != nil { - return err - } - - isLocal = answer == YesOption - } +func (cmd *StartCmd) promptEnableIngress(ctx context.Context) (bool, error) { + log.Info("Existing instance found.") - if isLocal { - // Confirm with user if ingress should be installed in local cluster - var ( - YesOption = "Yes, enable the ingress anyway" - NoOption = "No" - ) + // Check if Devsy is installed in a local cluster + isLocal := isInstalledLocally(ctx, cmd.KubeClient, cmd.Namespace) - answer, err := log.QuestionDefault(&survey.QuestionOptions{ - Question: "Enabling ingress is usually only useful for remote clusters. Do you still want to deploy the ingress to your local cluster?", - DefaultValue: NoOption, - Options: []string{ - NoOption, - YesOption, - }, - }) - if err != nil { - return err - } - - enableIngress = answer == YesOption - } + // Skip question if --host flag is provided + enableIngress := cmd.Host != "" + if !enableIngress { + return false, nil + } + + if isLocal { + confirmedLocal, err := cmd.confirmLocalCluster() + if err != nil { + return false, err } - // Check if we need to enable ingress - if enableIngress { - // Ask for hostname if --host flag is not provided - if cmd.Host == "" { - host, err := enterHostNameQuestion() - if err != nil { - return err - } - - cmd.Host = host - } else { - log.Info("Will enable an ingress with hostname: " + cmd.Host) - } + isLocal = confirmedLocal + } - if term.IsTerminal(os.Stdin) { - err := ensureIngressController(ctx, cmd.KubeClient, cmd.Context) - if err != nil { - return fmt.Errorf("install ingress controller: %w", err) - } - } + if isLocal { + anyway, err := cmd.confirmIngressOnLocal() + if err != nil { + return false, err } + + enableIngress = anyway } - // Only upgrade if --upgrade flag is present or user decided to enable ingress - if cmd.Upgrade || enableIngress { - err := cmd.upgrade(ctx) + if !enableIngress { + return false, nil + } + + if err := cmd.ensureHostAndIngress(ctx); err != nil { + return false, err + } + + return true, nil +} + +func (cmd *StartCmd) confirmLocalCluster() (bool, error) { + // Confirm with user if this is a local cluster + const ( + YesOption = "Yes" + NoOption = "No, my cluster is running not locally (GKE, EKS, Bare Metal, etc.)" + ) + + answer, err := log.QuestionDefault(&survey.QuestionOptions{ + Question: "Seems like your cluster is running locally (docker desktop, minikube, kind etc.). Is that correct?", + DefaultValue: YesOption, + Options: []string{ + YesOption, + NoOption, + }, + }) + if err != nil { + return false, err + } + + return answer == YesOption, nil +} + +func (cmd *StartCmd) confirmIngressOnLocal() (bool, error) { + // Confirm with user if ingress should be installed in local cluster + var ( + YesOption = "Yes, enable the ingress anyway" + NoOption = "No" + ) + + answer, err := log.QuestionDefault(&survey.QuestionOptions{ + Question: "Enabling ingress is usually only useful for remote clusters. " + + "Do you still want to deploy the ingress to your local cluster?", + DefaultValue: NoOption, + Options: []string{ + NoOption, + YesOption, + }, + }) + if err != nil { + return false, err + } + + return answer == YesOption, nil +} + +func (cmd *StartCmd) ensureHostAndIngress(ctx context.Context) error { + // Ask for hostname if --host flag is not provided + if cmd.Host == "" { + host, err := enterHostNameQuestion() if err != nil { return err } + + cmd.Host = host + } else { + log.Info("Will enable an ingress with hostname: " + cmd.Host) } - return cmd.success(ctx) + if term.IsTerminal(os.Stdin) { + err := ensureIngressController(ctx, cmd.KubeClient, cmd.Context) + if err != nil { + return fmt.Errorf("install ingress controller: %w", err) + } + } + + return nil } func (cmd *StartCmd) waitForDeployment(ctx context.Context) (*corev1.Pod, error) { @@ -1457,14 +1539,9 @@ func uninstall( restConfig *rest.Config, kubeContext, namespace string, ) error { - releaseName := config.ProReleaseName - deploy, err := kubeClient.AppsV1(). - Deployments(namespace). - Get(ctx, defaultDeploymentName, metav1.GetOptions{}) - if err != nil && !kerrors.IsNotFound(err) { + releaseName, err := resolveReleaseName(ctx, kubeClient, namespace) + if err != nil { return err - } else if deploy != nil && deploy.Labels != nil && deploy.Labels["release"] != "" { - releaseName = deploy.Labels["release"] } args := []string{ @@ -1488,10 +1565,12 @@ func uninstall( return err } - err = apiRegistrationClient.ApiregistrationV1(). - APIServices(). - Delete(ctx, "v1.management.devsy.sh", metav1.DeleteOptions{}) - if err != nil && !kerrors.IsNotFound(err) { + err = deleteIgnoreNotFound(func() error { + return apiRegistrationClient.ApiregistrationV1(). + APIServices(). + Delete(ctx, "v1.management.devsy.sh", metav1.DeleteOptions{}) + }) + if err != nil { return err } @@ -1500,60 +1579,79 @@ func uninstall( return err } - err = kubeClient.CoreV1(). - Secrets(namespace). - Delete(ctx, "loft-user-secret-admin", metav1.DeleteOptions{}) - if err != nil && !kerrors.IsNotFound(err) { - return err - } - - err = kubeClient.CoreV1(). - Secrets(namespace). - Delete(ctx, LoftRouterDomainSecret, metav1.DeleteOptions{}) - if err != nil && !kerrors.IsNotFound(err) { + err = deleteIgnoreNotFound( + func() error { + return kubeClient.CoreV1(). + Secrets(namespace). + Delete(ctx, "loft-user-secret-admin", metav1.DeleteOptions{}) + }, + func() error { + return kubeClient.CoreV1(). + Secrets(namespace). + Delete(ctx, LoftRouterDomainSecret, metav1.DeleteOptions{}) + }, + func() error { + return kubeClient.AdmissionregistrationV1(). + ValidatingWebhookConfigurations(). + Delete(ctx, "loft-agent", metav1.DeleteOptions{}) + }, + func() error { + return apiRegistrationClient.ApiregistrationV1(). + APIServices(). + Delete(ctx, "v1alpha1.tenancy.kiosk.sh", metav1.DeleteOptions{}) + }, + func() error { + return apiRegistrationClient.ApiregistrationV1(). + APIServices(). + Delete(ctx, "v1.cluster.devsy.sh", metav1.DeleteOptions{}) + }, + func() error { + return kubeClient.CoreV1(). + ConfigMaps(namespace). + Delete(ctx, "loft-agent-controller", metav1.DeleteOptions{}) + }, + func() error { + return kubeClient.CoreV1(). + ConfigMaps(namespace). + Delete(ctx, "loft-applied-defaults", metav1.DeleteOptions{}) + }, + ) + if err != nil { return err } - // we also cleanup the validating webhook configuration and apiservice - err = kubeClient.AdmissionregistrationV1(). - ValidatingWebhookConfigurations(). - Delete(ctx, "loft-agent", metav1.DeleteOptions{}) - if err != nil && !kerrors.IsNotFound(err) { - return err - } + fmt.Fprint(os.Stderr, "\n") + log.Info("uninstalled Devsy Pro") + fmt.Fprint(os.Stderr, "\n") - err = apiRegistrationClient.ApiregistrationV1(). - APIServices(). - Delete(ctx, "v1alpha1.tenancy.kiosk.sh", metav1.DeleteOptions{}) - if err != nil && !kerrors.IsNotFound(err) { - return err - } + return nil +} - err = apiRegistrationClient.ApiregistrationV1(). - APIServices(). - Delete(ctx, "v1.cluster.devsy.sh", metav1.DeleteOptions{}) +func resolveReleaseName( + ctx context.Context, + kubeClient kubernetes.Interface, + namespace string, +) (string, error) { + releaseName := config.ProReleaseName + deploy, err := kubeClient.AppsV1(). + Deployments(namespace). + Get(ctx, defaultDeploymentName, metav1.GetOptions{}) if err != nil && !kerrors.IsNotFound(err) { - return err + return "", err + } else if deploy != nil && deploy.Labels != nil && deploy.Labels["release"] != "" { + releaseName = deploy.Labels["release"] } - err = kubeClient.CoreV1(). - ConfigMaps(namespace). - Delete(ctx, "loft-agent-controller", metav1.DeleteOptions{}) - if err != nil && !kerrors.IsNotFound(err) { - return err - } + return releaseName, nil +} - err = kubeClient.CoreV1(). - ConfigMaps(namespace). - Delete(ctx, "loft-applied-defaults", metav1.DeleteOptions{}) - if err != nil && !kerrors.IsNotFound(err) { - return err +func deleteIgnoreNotFound(deletes ...func() error) error { + for _, del := range deletes { + if err := del(); err != nil && !kerrors.IsNotFound(err) { + return err + } } - fmt.Fprint(os.Stderr, "\n") - log.Info("uninstalled Devsy Pro") - fmt.Fprint(os.Stderr, "\n") - return nil } @@ -1663,71 +1761,92 @@ func ensureIngressController( } if answer == YesOption { - args := []string{ - "install", - "ingress-nginx", - "ingress-nginx", - "--repository-config=''", - "--repo", - "https://kubernetes.github.io/ingress-nginx", - "--kube-context", - kubeContext, - "--namespace", - "ingress-nginx", - "--create-namespace", - "--set-string", - "controller.config.hsts=false", - "--wait", - } - fmt.Fprint(os.Stderr, "\n") - log.Infof("Executing command: helm %s\n", strings.Join(args, " ")) - log.Info("Waiting for ingress controller deployment, this can take several minutes") - helmCmd := exec.CommandContext( - ctx, - "helm", - args...) // #nosec G204 -- helm args are constructed internally - output, err := helmCmd.CombinedOutput() - if err != nil { - return fmt.Errorf("error during helm command: %s (%w)", string(output), err) - } + return installNginxIngress(ctx, kubeClient, kubeContext) + } - list, err := kubeClient.CoreV1().Secrets("ingress-nginx").List(ctx, metav1.ListOptions{ - LabelSelector: "name=ingress-nginx,owner=helm,status=deployed", - }) - if err != nil { + return nil +} + +func installNginxIngress( + ctx context.Context, + kubeClient kubernetes.Interface, + kubeContext string, +) error { + args := []string{ + "install", + ingressNginx, + ingressNginx, + helmRepositoryConfig, + "--repo", + "https://kubernetes.github.io/ingress-nginx", + "--kube-context", + kubeContext, + "--namespace", + ingressNginx, + "--create-namespace", + "--set-string", + "controller.config.hsts=false", + "--wait", + } + fmt.Fprint(os.Stderr, "\n") + log.Infof("Executing command: helm %s\n", strings.Join(args, " ")) + log.Info("Waiting for ingress controller deployment, this can take several minutes") + helmCmd := exec.CommandContext( + ctx, + "helm", + args...) // #nosec G204 -- helm args are constructed internally + output, err := helmCmd.CombinedOutput() + if err != nil { + return fmt.Errorf("error during helm command: %s (%w)", string(output), err) + } + + list, err := kubeClient.CoreV1().Secrets(ingressNginx).List(ctx, metav1.ListOptions{ + LabelSelector: "name=ingress-nginx,owner=helm,status=deployed", + }) + if err != nil { + return err + } + + if len(list.Items) == 1 { + if err := labelIngressSecret(ctx, kubeClient, list.Items[0]); err != nil { return err } + } - if len(list.Items) == 1 { - secret := list.Items[0] - originalSecret := secret.DeepCopy() - secret.Labels["devsy.sh/app"] = "true" - if secret.Annotations == nil { - secret.Annotations = map[string]string{} - } + log.Info("installed ingress-nginx to your kubernetes cluster!") - secret.Annotations["devsy.sh/url"] = "https://kubernetes.github.io/ingress-nginx" - originalJSON, err := json.Marshal(originalSecret) - if err != nil { - return err - } - modifiedJSON, err := json.Marshal(secret) - if err != nil { - return err - } - data, err := jsonpatch.CreateMergePatch(originalJSON, modifiedJSON) - if err != nil { - return err - } - _, err = kubeClient.CoreV1(). - Secrets(secret.Namespace). - Patch(ctx, secret.Name, types.MergePatchType, data, metav1.PatchOptions{}) - if err != nil { - return err - } - } + return nil +} + +func labelIngressSecret( + ctx context.Context, + kubeClient kubernetes.Interface, + secret corev1.Secret, +) error { + originalSecret := secret.DeepCopy() + secret.Labels["devsy.sh/app"] = trueString + if secret.Annotations == nil { + secret.Annotations = map[string]string{} + } - log.Info("installed ingress-nginx to your kubernetes cluster!") + secret.Annotations["devsy.sh/url"] = "https://kubernetes.github.io/ingress-nginx" + originalJSON, err := json.Marshal(originalSecret) + if err != nil { + return err + } + modifiedJSON, err := json.Marshal(secret) + if err != nil { + return err + } + data, err := jsonpatch.CreateMergePatch(originalJSON, modifiedJSON) + if err != nil { + return err + } + _, err = kubeClient.CoreV1(). + Secrets(secret.Namespace). + Patch(ctx, secret.Name, types.MergePatchType, data, metav1.PatchOptions{}) + if err != nil { + return err } return nil @@ -1797,13 +1916,24 @@ func ensureAdminPassword( if err != nil { return false, err } - case admin.Spec.PasswordRef == nil || - admin.Spec.PasswordRef.SecretName == "" || - admin.Spec.PasswordRef.SecretNamespace == "": + case passwordRefIncomplete(admin.Spec.PasswordRef): return false, nil } - key := admin.Spec.PasswordRef.Key + return ensureAdminPasswordSecret(ctx, kubeClient, admin.Spec.PasswordRef, password) +} + +func passwordRefIncomplete(ref *storagev1.SecretRef) bool { + return ref == nil || ref.SecretName == "" || ref.SecretNamespace == "" +} + +func ensureAdminPasswordSecret( + ctx context.Context, + kubeClient kubernetes.Interface, + ref *storagev1.SecretRef, + password string, +) (bool, error) { + key := ref.Key if key == "" { key = "password" } @@ -1811,8 +1941,8 @@ func ensureAdminPassword( passwordHash := fmt.Sprintf("%x", sha256.Sum256([]byte(password))) secret, err := kubeClient.CoreV1(). - Secrets(admin.Spec.PasswordRef.SecretNamespace). - Get(ctx, admin.Spec.PasswordRef.SecretName, metav1.GetOptions{}) + Secrets(ref.SecretNamespace). + Get(ctx, ref.SecretName, metav1.GetOptions{}) if err != nil && !kerrors.IsNotFound(err) { return false, err } else if err == nil { @@ -1834,8 +1964,8 @@ func ensureAdminPassword( // create the password secret if it was not found, this can happen if you delete the loft namespace without deleting the admin user secret = &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ - Name: admin.Spec.PasswordRef.SecretName, - Namespace: admin.Spec.PasswordRef.SecretNamespace, + Name: ref.SecretName, + Namespace: ref.SecretNamespace, }, Data: map[string][]byte{ key: []byte(passwordHash), @@ -1945,7 +2075,7 @@ func upgradeRelease( chartName, "--install", "--create-namespace", - "--repository-config=''", + helmRepositoryConfig, "--kube-context", kubeContext, "--namespace", @@ -1989,7 +2119,7 @@ func getReleaseManifests( "template", defaultReleaseName, chartName, - "--repository-config=''", + helmRepositoryConfig, "--kube-context", kubeContext, "--namespace", diff --git a/cmd/pro/update_provider.go b/cmd/pro/update_provider.go index c51f74974..e7a77e5d9 100644 --- a/cmd/pro/update_provider.go +++ b/cmd/pro/update_provider.go @@ -61,18 +61,10 @@ func (cmd *UpdateProviderCmd) Run(ctx context.Context, args []string) error { if provider.Source.Internal { return nil } - providerSource, err := workspace.ResolveProviderSource( - devsyConfig, - provider.Name, - ) + providerSource, err := resolveNewProviderSource(devsyConfig, provider.Name, newVersion) if err != nil { - return fmt.Errorf("resolve provider source %s: %w", provider.Name, err) - } - splitted := strings.Split(providerSource, "@") - if len(splitted) == 0 { - return fmt.Errorf("no provider source found %s", providerSource) + return err } - providerSource = splitted[0] + "@" + newVersion _, err = workspace.UpdateProvider(ctx, devsyConfig, provider.Name, providerSource) if err != nil { @@ -97,3 +89,22 @@ func (cmd *UpdateProviderCmd) Run(ctx context.Context, args []string) error { return nil } + +func resolveNewProviderSource( + devsyConfig *config.Config, + providerName, newVersion string, +) (string, error) { + providerSource, err := workspace.ResolveProviderSource( + devsyConfig, + providerName, + ) + if err != nil { + return "", fmt.Errorf("resolve provider source %s: %w", providerName, err) + } + splitted := strings.Split(providerSource, "@") + if len(splitted) == 0 { + return "", fmt.Errorf("no provider source found %s", providerSource) + } + + return splitted[0] + "@" + newVersion, nil +} diff --git a/cmd/pro/workspace/import.go b/cmd/pro/workspace/import.go index f72354f14..ac30f952d 100644 --- a/cmd/pro/workspace/import.go +++ b/cmd/pro/workspace/import.go @@ -81,42 +81,77 @@ func (cmd *ImportCmd) Run(ctx context.Context, args []string) error { return err } + done, err := cmd.resolveWorkspaceID(devsyConfig) + if err != nil { + return err + } + if done { + return nil + } + + ref, err := cmd.findWorkspaceInstance(ctx, devsyConfig, devsyProHost) + if err != nil { + return err + } + + return cmd.importInstance(ctx, devsyConfig, ref) +} + +// resolveWorkspaceID sets the target workspace ID and reports whether the +// workspace has already been imported (done == true). +func (cmd *ImportCmd) resolveWorkspaceID(devsyConfig *config.Config) (bool, error) { // set uid as id if cmd.WorkspaceId == "" { cmd.WorkspaceId = cmd.WorkspaceUid } // check if workspace already exists - if provider2.WorkspaceExists(devsyConfig.DefaultContext, cmd.WorkspaceId) { - workspaceConfig, err := provider2.LoadWorkspaceConfig( - devsyConfig.DefaultContext, - cmd.WorkspaceId, - ) - if err != nil { - return fmt.Errorf("load workspace: %w", err) - } else if workspaceConfig.UID == cmd.WorkspaceUid { - log.Infof("Workspace %s already imported", cmd.WorkspaceId) - return nil - } + if !provider2.WorkspaceExists(devsyConfig.DefaultContext, cmd.WorkspaceId) { + return false, nil + } - newWorkspaceId := cmd.WorkspaceId + "-" + random.String(5) - if provider2.WorkspaceExists(devsyConfig.DefaultContext, newWorkspaceId) { - return fmt.Errorf("workspace %s already exists", cmd.WorkspaceId) - } + workspaceConfig, err := provider2.LoadWorkspaceConfig( + devsyConfig.DefaultContext, + cmd.WorkspaceId, + ) + if err != nil { + return false, fmt.Errorf("load workspace: %w", err) + } else if workspaceConfig.UID == cmd.WorkspaceUid { + log.Infof("Workspace %s already imported", cmd.WorkspaceId) + return true, nil + } - log.Infof( - "workspace ID conflict, will import workspace with new ID: "+ - "existingWorkspaceId=%s, existingWorkspaceUid=%s, newWorkspaceId=%s", - cmd.WorkspaceId, - workspaceConfig.UID, - newWorkspaceId, - ) - cmd.WorkspaceId = newWorkspaceId + newWorkspaceId := cmd.WorkspaceId + "-" + random.String(5) + if provider2.WorkspaceExists(devsyConfig.DefaultContext, newWorkspaceId) { + return false, fmt.Errorf("workspace %s already exists", newWorkspaceId) } + log.Infof( + "workspace ID conflict, will import workspace with new ID: "+ + "existingWorkspaceId=%s, existingWorkspaceUid=%s, newWorkspaceId=%s", + cmd.WorkspaceId, + workspaceConfig.UID, + newWorkspaceId, + ) + cmd.WorkspaceId = newWorkspaceId + + return false, nil +} + +type workspaceInstanceRef struct { + provider *provider2.ProviderConfig + baseClient client.Client + instance *managementv1.DevsyWorkspaceInstance +} + +func (cmd *ImportCmd) findWorkspaceInstance( + ctx context.Context, + devsyConfig *config.Config, + devsyProHost string, +) (workspaceInstanceRef, error) { provider, err := workspace.ProviderFromHost(ctx, devsyConfig, devsyProHost) if err != nil { - return fmt.Errorf("resolve provider: %w", err) + return workspaceInstanceRef{}, fmt.Errorf("resolve provider: %w", err) } baseClient, err := platform.InitClientFromProvider( @@ -125,25 +160,40 @@ func (cmd *ImportCmd) Run(ctx context.Context, args []string) error { provider.Name, ) if err != nil { - return fmt.Errorf("base client: %w", err) + return workspaceInstanceRef{}, fmt.Errorf("base client: %w", err) } opts := platform.FindInstanceOptions{UID: cmd.WorkspaceUid, ProjectName: cmd.WorkspaceProject} instance, err := platform.FindInstance(ctx, baseClient, opts) if err != nil { - return fmt.Errorf("find workspace instance: %w", err) + return workspaceInstanceRef{}, fmt.Errorf("find workspace instance: %w", err) } if instance == nil { - return fmt.Errorf("workspace instance with UID %s not found", cmd.WorkspaceUid) + return workspaceInstanceRef{}, fmt.Errorf( + "workspace instance with UID %s not found", + cmd.WorkspaceUid, + ) } + return workspaceInstanceRef{ + provider: provider, + baseClient: baseClient, + instance: instance, + }, nil +} + +func (cmd *ImportCmd) importInstance( + ctx context.Context, + devsyConfig *config.Config, + ref workspaceInstanceRef, +) error { // old pro provider - if !provider.HasHealthCheck() { - instanceOpts, err := resolveInstanceOptions(ctx, instance, baseClient) + if !ref.provider.HasHealthCheck() { + instanceOpts, err := resolveInstanceOptions(ctx, ref.instance, ref.baseClient) if err != nil { return fmt.Errorf("resolve instance options: %w", err) } - err = cmd.writeWorkspaceDefinition(devsyConfig, provider, instanceOpts, instance) + err = cmd.writeWorkspaceDefinition(devsyConfig, ref.provider, instanceOpts, ref.instance) if err != nil { return fmt.Errorf("prepare workspace to import definition: %w", err) } @@ -152,7 +202,7 @@ func (cmd *ImportCmd) Run(ctx context.Context, args []string) error { } // new pro provider - err = cmd.writeNewWorkspaceDefinition(devsyConfig, instance, provider.Name) + err := cmd.writeNewWorkspaceDefinition(devsyConfig, ref.instance, ref.provider.Name) if err != nil { return fmt.Errorf("prepare workspace to import definition: %w", err) } @@ -255,35 +305,60 @@ func resolveInstanceOptions( if instance.Spec.Parameters == "" { return opts, nil } - managementClient, err := baseClient.Management() + + err := resolveTemplateParameters(ctx, resolveTemplateParametersParams{ + instance: instance, + baseClient: baseClient, + projectName: projectName, + opts: opts, + }) if err != nil { - return nil, fmt.Errorf("get management client: %w", err) + return nil, err + } + + return opts, nil +} + +type resolveTemplateParametersParams struct { + instance *managementv1.DevsyWorkspaceInstance + baseClient client.Client + projectName string + opts map[string]string +} + +func resolveTemplateParameters( + ctx context.Context, + params resolveTemplateParametersParams, +) error { + managementClient, err := params.baseClient.Management() + if err != nil { + return fmt.Errorf("get management client: %w", err) } template, err := list.FindTemplate( ctx, managementClient, - projectName, - instance.Spec.TemplateRef.Name, + params.projectName, + params.instance.Spec.TemplateRef.Name, ) if err != nil { - return nil, fmt.Errorf("find template: %w", err) + return fmt.Errorf("find template: %w", err) } templateParameters := template.Spec.Parameters if len(template.Spec.Versions) > 0 { templateParameters, err = list.GetTemplateParameters( template, - instance.Spec.TemplateRef.Version, + params.instance.Spec.TemplateRef.Version, ) if err != nil { - return nil, fmt.Errorf("get template parameters: %w", err) + return fmt.Errorf("get template parameters: %w", err) } } - err = fillParameterOptions(opts, templateParameters, instance.Spec.Parameters) + err = fillParameterOptions(params.opts, templateParameters, params.instance.Spec.Parameters) if err != nil { - return nil, fmt.Errorf("fill parameter options: %w", err) + return fmt.Errorf("fill parameter options: %w", err) } - return opts, nil + return nil } func fillParameterOptions( @@ -299,26 +374,12 @@ func fillParameterOptions( for _, parameter := range parameterDefinitions { val := parameters.GetDeepValue(parametersMap, parameter.Variable) - var strVal string - if val != nil { - switch t := val.(type) { - case string: - strVal = t - case int: - strVal = strconv.Itoa(t) - case bool: - strVal = strconv.FormatBool(t) - default: - return fmt.Errorf( - "unrecognized type for parameter %s (%s) in file: %v", - parameter.Label, - parameter.Variable, - t, - ) - } + strVal, err := parameterValueString(val, parameter) + if err != nil { + return err } - _, err := parameters.VerifyValue(strVal, parameter) + _, err = parameters.VerifyValue(strVal, parameter) if err != nil { return err } @@ -329,3 +390,25 @@ func fillParameterOptions( return nil } + +func parameterValueString(val any, parameter storagev1.AppParameter) (string, error) { + switch t := val.(type) { + case nil: + return "", nil + case string: + return t, nil + case int: + return strconv.Itoa(t), nil + case float64: + return strconv.FormatFloat(t, 'f', -1, 64), nil + case bool: + return strconv.FormatBool(t), nil + default: + return "", fmt.Errorf( + "unrecognized type for parameter %s (%s) in file: %v", + parameter.Label, + parameter.Variable, + t, + ) + } +} diff --git a/cmd/pro/workspace/rebuild.go b/cmd/pro/workspace/rebuild.go index 9095b7e35..650baec6d 100644 --- a/cmd/pro/workspace/rebuild.go +++ b/cmd/pro/workspace/rebuild.go @@ -7,11 +7,13 @@ import ( "net/url" "os" + managementv1 "github.com/devsy-org/api/pkg/apis/management/v1" "github.com/devsy-org/devsy/cmd/pro/flags" "github.com/devsy-org/devsy/pkg/config" cliflags "github.com/devsy-org/devsy/pkg/flags" "github.com/devsy-org/devsy/pkg/flags/names" "github.com/devsy-org/devsy/pkg/platform" + "github.com/devsy-org/devsy/pkg/platform/client" "github.com/devsy-org/devsy/pkg/platform/remotecommand" "github.com/spf13/cobra" ) @@ -72,6 +74,14 @@ func (cmd *RebuildCmd) Run(ctx context.Context, args []string) error { return fmt.Errorf("workspace %q not found in project %q", targetWorkspace, cmd.Project) } + return execRebuild(ctx, baseClient, workspace) +} + +func execRebuild( + ctx context.Context, + baseClient client.Client, + workspace *managementv1.DevsyWorkspaceInstance, +) error { opts := struct { Recreate bool `json:"recreate"` }{Recreate: true} diff --git a/cmd/pro/workspace/sleep.go b/cmd/pro/workspace/sleep.go index e6caf15e0..1674a0bd4 100644 --- a/cmd/pro/workspace/sleep.go +++ b/cmd/pro/workspace/sleep.go @@ -7,6 +7,7 @@ import ( "time" clusterv1 "github.com/devsy-org/agentapi/pkg/apis/devsy/cluster/v1" + managementv1 "github.com/devsy-org/api/pkg/apis/management/v1" storagev1 "github.com/devsy-org/api/pkg/apis/storage/v1" "github.com/devsy-org/devsy/cmd/pro/flags" "github.com/devsy-org/devsy/pkg/config" @@ -14,6 +15,8 @@ import ( "github.com/devsy-org/devsy/pkg/flags/names" "github.com/devsy-org/devsy/pkg/log" "github.com/devsy-org/devsy/pkg/platform" + "github.com/devsy-org/devsy/pkg/platform/client" + "github.com/devsy-org/devsy/pkg/platform/kube" "github.com/devsy-org/devsy/pkg/platform/project" "github.com/spf13/cobra" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -84,12 +87,48 @@ func (cmd *SleepCmd) Run(ctx context.Context, args []string) error { if err != nil { return err } + if workspaceInstance == nil { + return fmt.Errorf("workspace %q not found in project %q", targetWorkspace, cmd.Project) + } + + return cmd.sleep(ctx, baseClient, workspaceInstance) +} +func (cmd *SleepCmd) sleep( + ctx context.Context, + baseClient client.Client, + workspaceInstance *managementv1.DevsyWorkspaceInstance, +) error { managementClient, err := baseClient.Management() if err != nil { return err } + if err := cmd.patchSleep(ctx, managementClient, workspaceInstance); err != nil { + return err + } + + // wait for sleeping + log.Info("Wait until workspace is sleeping") + err = waitForWorkspacePhase(ctx, waitForWorkspacePhaseParams{ + managementClient: managementClient, + projectName: cmd.Project, + name: workspaceInstance.Name, + phase: storagev1.InstanceSleeping, + }) + if err != nil { + return fmt.Errorf("error waiting for workspace to start sleeping: %w", err) + } + + log.Infof("workspace is now sleeping: workspace=%s", workspaceInstance.Name) + return nil +} + +func (cmd *SleepCmd) patchSleep( + ctx context.Context, + managementClient kube.Interface, + workspaceInstance *managementv1.DevsyWorkspaceInstance, +) error { // create a deep copy of the workspace instance oldWorkspaceInstance := workspaceInstance.DeepCopy() oldWorkspaceInstance.Status = workspaceInstance.Status @@ -117,33 +156,36 @@ func (cmd *SleepCmd) Run(ctx context.Context, args []string) error { ManagementV1(). DevsyWorkspaceInstances(project.ProjectNamespace(cmd.Project)). Patch(ctx, workspaceInstance.Name, patch.Type(), patchData, metav1.PatchOptions{}) - if err != nil { - return err - } - // wait for sleeping - log.Info("Wait until workspace is sleeping") - err = wait.PollUntilContextTimeout( + return err +} + +type waitForWorkspacePhaseParams struct { + managementClient kube.Interface + projectName string + name string + phase storagev1.InstancePhase +} + +func waitForWorkspacePhase( + ctx context.Context, + params waitForWorkspacePhaseParams, +) error { + return wait.PollUntilContextTimeout( ctx, time.Second, platform.Timeout(), false, func(ctx context.Context) (done bool, err error) { - workspaceInstance, err := managementClient.Loft(). + workspaceInstance, err := params.managementClient.Loft(). ManagementV1(). - DevsyWorkspaceInstances(project.ProjectNamespace(cmd.Project)). - Get(ctx, workspaceInstance.Name, metav1.GetOptions{}) + DevsyWorkspaceInstances(project.ProjectNamespace(params.projectName)). + Get(ctx, params.name, metav1.GetOptions{}) if err != nil { return false, err } - return workspaceInstance.Status.Phase == storagev1.InstanceSleeping, nil + return workspaceInstance.Status.Phase == params.phase, nil }, ) - if err != nil { - return fmt.Errorf("error waiting for workspace to start sleeping: %w", err) - } - - log.Infof("workspace is now sleeping: workspace=%s", workspaceInstance.Name) - return nil } diff --git a/cmd/pro/workspace/wakeup.go b/cmd/pro/workspace/wakeup.go index 29fa377b2..bf09ea249 100644 --- a/cmd/pro/workspace/wakeup.go +++ b/cmd/pro/workspace/wakeup.go @@ -7,6 +7,7 @@ import ( "time" clusterv1 "github.com/devsy-org/agentapi/pkg/apis/devsy/cluster/v1" + managementv1 "github.com/devsy-org/api/pkg/apis/management/v1" storagev1 "github.com/devsy-org/api/pkg/apis/storage/v1" "github.com/devsy-org/devsy/cmd/pro/flags" "github.com/devsy-org/devsy/pkg/config" @@ -14,10 +15,11 @@ import ( "github.com/devsy-org/devsy/pkg/flags/names" "github.com/devsy-org/devsy/pkg/log" "github.com/devsy-org/devsy/pkg/platform" + "github.com/devsy-org/devsy/pkg/platform/client" + "github.com/devsy-org/devsy/pkg/platform/kube" "github.com/devsy-org/devsy/pkg/platform/project" "github.com/spf13/cobra" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/util/wait" ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" ) @@ -73,7 +75,18 @@ func (cmd *WakeupCmd) Run(ctx context.Context, args []string) error { if err != nil { return err } + if workspaceInstance == nil { + return fmt.Errorf("workspace %q not found in project %q", targetWorkspace, cmd.Project) + } + + return cmd.wakeup(ctx, baseClient, workspaceInstance) +} +func (cmd *WakeupCmd) wakeup( + ctx context.Context, + baseClient client.Client, + workspaceInstance *managementv1.DevsyWorkspaceInstance, +) error { if workspaceInstance.Status.Phase != storagev1.InstanceSleeping { log.Infof("Workspace %s is not sleeping", workspaceInstance.Name) return nil @@ -84,6 +97,31 @@ func (cmd *WakeupCmd) Run(ctx context.Context, args []string) error { return err } + if err := cmd.patchWakeup(ctx, managementClient, workspaceInstance); err != nil { + return err + } + + // wait for sleeping + log.Info("Wait until workspace wakes up") + err = waitForWorkspacePhase(ctx, waitForWorkspacePhaseParams{ + managementClient: managementClient, + projectName: cmd.Project, + name: workspaceInstance.Name, + phase: storagev1.InstanceReady, + }) + if err != nil { + return fmt.Errorf("error waiting for workspace to wake up: %w", err) + } + + log.Infof("woke up workspace: workspaceName=%s", workspaceInstance.Name) + return nil +} + +func (cmd *WakeupCmd) patchWakeup( + ctx context.Context, + managementClient kube.Interface, + workspaceInstance *managementv1.DevsyWorkspaceInstance, +) error { // create a deep copy of the workspace instance oldWorkspaceInstance := workspaceInstance.DeepCopy() oldWorkspaceInstance.Status = workspaceInstance.Status @@ -111,33 +149,6 @@ func (cmd *WakeupCmd) Run(ctx context.Context, args []string) error { ManagementV1(). DevsyWorkspaceInstances(project.ProjectNamespace(cmd.Project)). Patch(ctx, workspaceInstance.Name, patch.Type(), patchData, metav1.PatchOptions{}) - if err != nil { - return err - } - - // wait for sleeping - log.Info("Wait until workspace wakes up") - err = wait.PollUntilContextTimeout( - ctx, - time.Second, - platform.Timeout(), - false, - func(ctx context.Context) (done bool, err error) { - workspaceInstance, err := managementClient.Loft(). - ManagementV1(). - DevsyWorkspaceInstances(project.ProjectNamespace(cmd.Project)). - Get(ctx, workspaceInstance.Name, metav1.GetOptions{}) - if err != nil { - return false, err - } - - return workspaceInstance.Status.Phase == storagev1.InstanceReady, nil - }, - ) - if err != nil { - return fmt.Errorf("error waiting for workspace to wake up: %w", err) - } - log.Infof("woke up workspace: workspaceName=%s", workspaceInstance.Name) - return nil + return err } diff --git a/cmd/provider/add.go b/cmd/provider/add.go index 3ae20f716..54769db41 100644 --- a/cmd/provider/add.go +++ b/cmd/provider/add.go @@ -81,23 +81,49 @@ func NewAddCmd(f *flags.GlobalFlags) *cobra.Command { func (cmd *AddCmd) Run(ctx context.Context, devsyConfig *config.Config, args []string) error { providerName := cmd.Name - if providerName != "" { - if provider.ProviderNameRegEx.MatchString(providerName) { - return fmt.Errorf( - "provider name can only include lowercase letters, numbers or dashes", - ) - } - if len(providerName) > 32 { - return fmt.Errorf("provider name cannot be longer than 32 characters") - } + if err := validateOptionalProviderName(providerName); err != nil { + return err + } + + providerConfig, options, err := cmd.resolveProviderConfig(ctx, devsyConfig, providerName, args) + if err != nil { + return err + } + + log.Infof("installed provider: providerName=%s", providerConfig.Name) + if !cmd.Use { + log.Infof("To initialize the provider, run: devsy provider init %s", providerConfig.Name) + return nil + } + + return cmd.useProvider(ctx, devsyConfig, providerConfig, options) +} + +func validateOptionalProviderName(providerName string) error { + if providerName == "" { + return nil + } + if provider.ProviderNameRegEx.MatchString(providerName) { + return fmt.Errorf( + "provider name can only include lowercase letters, numbers or dashes", + ) + } + if len(providerName) > 32 { + return fmt.Errorf("provider name cannot be longer than 32 characters") } + return nil +} - var providerConfig *provider.ProviderConfig - var options []string +func (cmd *AddCmd) resolveProviderConfig( + ctx context.Context, + devsyConfig *config.Config, + providerName string, + args []string, +) (*provider.ProviderConfig, []string, error) { if cmd.FromExisting != "" { if devsyConfig.Current() == nil || devsyConfig.Current().Providers[cmd.FromExisting] == nil { - return fmt.Errorf("provider %s does not exist", cmd.FromExisting) + return nil, nil, fmt.Errorf("provider %s does not exist", cmd.FromExisting) } providerWithOptions, err := workspace.CloneProvider( ctx, @@ -106,59 +132,58 @@ func (cmd *AddCmd) Run(ctx context.Context, devsyConfig *config.Config, args []s cmd.FromExisting, ) if err != nil { - return err + return nil, nil, err } - providerConfig = providerWithOptions.Config - options = mergeOptions( + return providerWithOptions.Config, mergeOptions( providerWithOptions.Config.Options, providerWithOptions.State.Options, cmd.Options, - ) - } else { - if len(args) != 1 { - return fmt.Errorf("specify either a URL or path, " + - "e.g. devsy provider add https://path/to/my/provider.yaml") - } - c, err := workspace.AddProvider(ctx, devsyConfig, providerName, args[0]) - if err != nil { - return err - } - providerConfig = c - options = cmd.Options + ), nil } - log.Infof("installed provider: providerName=%s", providerConfig.Name) - if cmd.Use { - // First add: there are no prior user values to merge, so - // DiscardPriorValues is moot. Set it explicitly so future readers - // don't wonder whether merging matters here. - configureErr := ConfigureProvider(ctx, ProviderOptionsConfig{ - Provider: providerConfig, - ContextName: devsyConfig.DefaultContext, - UserOptions: options, - DiscardPriorValues: true, - SingleMachine: &cmd.SingleMachine, - }) - if configureErr != nil { - devsyConfig, err := config.LoadConfig(cmd.Context, "") - if err != nil { - return err - } + if len(args) != 1 { + return nil, nil, fmt.Errorf("specify either a URL or path, " + + "e.g. devsy provider add https://path/to/my/provider.yaml") + } + c, err := workspace.AddProvider(ctx, devsyConfig, providerName, args[0]) + if err != nil { + return nil, nil, err + } + return c, cmd.Options, nil +} - err = DeleteProvider(ctx, devsyConfig, providerConfig.Name, true, true) - if err != nil { - return fmt.Errorf("delete provider: %w", err) - } +func (cmd *AddCmd) useProvider( + ctx context.Context, + devsyConfig *config.Config, + providerConfig *provider.ProviderConfig, + options []string, +) error { + // First add: there are no prior user values to merge, so + // DiscardPriorValues is moot. Set it explicitly so future readers + // don't wonder whether merging matters here. + configureErr := ConfigureProvider(ctx, ProviderOptionsConfig{ + Provider: providerConfig, + ContextName: devsyConfig.DefaultContext, + UserOptions: options, + DiscardPriorValues: true, + SingleMachine: &cmd.SingleMachine, + }) + if configureErr != nil { + devsyConfig, err := config.LoadConfig(cmd.Context, "") + if err != nil { + return err + } - return fmt.Errorf("configure provider: %w", configureErr) + err = DeleteProvider(ctx, devsyConfig, providerConfig.Name, true, true) + if err != nil { + return fmt.Errorf("delete provider: %w", err) } - return writeDefaultProvider(cmd.Context, providerConfig.Name) + return fmt.Errorf("configure provider: %w", configureErr) } - log.Infof("To initialize the provider, run: devsy provider init %s", providerConfig.Name) - return nil + return writeDefaultProvider(cmd.Context, providerConfig.Name) } // mergeOptions combines user options with existing options, user provided options take precedence. diff --git a/cmd/workspace/build.go b/cmd/workspace/build.go index a51b7961e..891332eb5 100644 --- a/cmd/workspace/build.go +++ b/cmd/workspace/build.go @@ -287,16 +287,18 @@ func (cmd *BuildCmd) build( return nil } - containerID := devcconfig.GetContainerID(result) + writeBuildResultJSON(result) + return nil +} + +func writeBuildResultJSON(result *devcconfig.Result) { workdir := "" if result != nil && result.SubstitutionContext != nil { workdir = result.SubstitutionContext.ContainerWorkspaceFolder } - user := devcconfig.GetRemoteUser(result) _ = devcconfig.WriteResultJSON(os.Stdout, devcconfig.ResultEnvelope{ - ContainerID: containerID, - RemoteUser: user, + ContainerID: devcconfig.GetContainerID(result), + RemoteUser: devcconfig.GetRemoteUser(result), RemoteWorkspaceFolder: workdir, }) - return nil } diff --git a/cmd/workspace/exec.go b/cmd/workspace/exec.go index c5ea42f6c..ef9db97e1 100644 --- a/cmd/workspace/exec.go +++ b/cmd/workspace/exec.go @@ -96,43 +96,61 @@ func NewExecCmd(f *flags.GlobalFlags) *cobra.Command { } func (cmd *ExecCmd) Run(ctx context.Context, args []string) error { + cmd.warnUnsupportedFlags() + + if err := cmd.validateExecFlags(); err != nil { + return err + } + + // Must run before the container-id branch below, else name+container-id would + // silently take the container path instead of erroring. + if cmd.WorkspaceName != "" && (cmd.WorkspaceFolder != "" || cmd.ContainerID != "") { + return errFolderNameConflict + } + + if cmd.ContainerID != "" { + return cmd.runWithContainerID(ctx, args) + } + + return cmd.runWithWorkspace(ctx, args) +} + +func (cmd *ExecCmd) warnUnsupportedFlags() { if cmd.ContainerDataFolder != "" { log.Warnf("--container-data-folder is accepted but not yet implemented for exec") } if cmd.SkipPostCreate { log.Warnf("--skip-post-create is accepted but not yet implemented for exec") } +} +func (cmd *ExecCmd) validateExecFlags() error { if err := cmd.validateRemoteEnv(); err != nil { return err } if err := devcconfig.ValidateIDLabels(cmd.IDLabels); err != nil { return err } - if _, err := output.ResolveMode(cmd.ResultFormat); err != nil { return err } + return nil +} - // Must run before the container-id branch below, else name+container-id would - // silently take the container path instead of erroring. - if cmd.WorkspaceName != "" && (cmd.WorkspaceFolder != "" || cmd.ContainerID != "") { - return errFolderNameConflict - } - - if cmd.ContainerID != "" { - return cmd.runWithContainerID(ctx, args) - } - +func (cmd *ExecCmd) resolveExecArgs() ([]string, error) { cwd := "" if cmd.WorkspaceName == "" && cmd.WorkspaceFolder == "" { var err error cwd, err = os.Getwd() if err != nil { - return fmt.Errorf("determine current directory: %w", err) + return nil, fmt.Errorf("determine current directory: %w", err) } } - getArgs, err := resolveExecTarget(cmd, cwd) + return resolveExecTarget(cmd, cwd) +} + +func (cmd *ExecCmd) runWithWorkspace(ctx context.Context, args []string) error { + getArgs, err := cmd.resolveExecArgs() if err != nil { return err } @@ -176,20 +194,32 @@ func (cmd *ExecCmd) Run(ctx context.Context, args []string) error { probedEnv := runtime.ProbeEnv(ctx, target, userEnvProbe) envMap := workspace2.BuildExecEnv(result, cmd.RemoteEnv, probedEnv) - mode, err := output.ResolveMode(cmd.ResultFormat) - if err != nil { - return err - } - emitJSON := mode == output.ModeJSON - - err = cmd.execInContainer(ctx, execOpts{ + return cmd.execAndReport(ctx, execOpts{ dockerCmd: runtime.Command(), dockerEnv: runtime.Environment(), target: target, workdir: workdir, envMap: envMap, - }, args) + }, args, devcconfig.ResultEnvelope{ + ContainerID: containerDetails.ID, + RemoteUser: user, + RemoteWorkspaceFolder: workdir, + }) +} + +func (cmd *ExecCmd) execAndReport( + ctx context.Context, + opts execOpts, + args []string, + envelope devcconfig.ResultEnvelope, +) error { + mode, err := output.ResolveMode(cmd.ResultFormat) if err != nil { + return err + } + emitJSON := mode == output.ModeJSON + + if err := cmd.execInContainer(ctx, opts, args); err != nil { if emitJSON { _ = devcconfig.WriteErrorJSON(os.Stderr, err.Error()) } @@ -197,11 +227,7 @@ func (cmd *ExecCmd) Run(ctx context.Context, args []string) error { } if emitJSON { - _ = devcconfig.WriteResultJSON(os.Stderr, devcconfig.ResultEnvelope{ - ContainerID: containerDetails.ID, - RemoteUser: user, - RemoteWorkspaceFolder: workdir, - }) + _ = devcconfig.WriteResultJSON(os.Stderr, envelope) } return nil } @@ -237,33 +263,16 @@ func (cmd *ExecCmd) runWithContainerID(ctx context.Context, args []string) error workdir := containerDetails.Config.WorkingDir - mode, err := output.ResolveMode(cmd.ResultFormat) - if err != nil { - return err - } - emitJSON := mode == output.ModeJSON - - err = cmd.execInContainer(ctx, execOpts{ + return cmd.execAndReport(ctx, execOpts{ dockerCmd: runtime.Command(), dockerEnv: runtime.Environment(), target: target, workdir: workdir, envMap: envMap, - }, args) - if err != nil { - if emitJSON { - _ = devcconfig.WriteErrorJSON(os.Stderr, err.Error()) - } - return err - } - - if emitJSON { - _ = devcconfig.WriteResultJSON(os.Stderr, devcconfig.ResultEnvelope{ - ContainerID: containerDetails.ID, - RemoteWorkspaceFolder: workdir, - }) - } - return nil + }, args, devcconfig.ResultEnvelope{ + ContainerID: containerDetails.ID, + RemoteWorkspaceFolder: workdir, + }) } var errFolderNameConflict = fmt.Errorf( diff --git a/cmd/workspace/import.go b/cmd/workspace/import.go index 0c2ce37e7..de4a76403 100644 --- a/cmd/workspace/import.go +++ b/cmd/workspace/import.go @@ -190,20 +190,8 @@ func (cmd *ImportCmd) importMachine( return fmt.Errorf("get machine dir: %w", err) } - // #nosec G301 -- TODO Consider using a more secure permission setting and ownership if needed. - err = os.MkdirAll(machineDir, 0o755) - if err != nil { - return fmt.Errorf("create machine dir: %w", err) - } - - decoded, err := base64.RawStdEncoding.DecodeString(exportConfig.Machine.Data) - if err != nil { - return fmt.Errorf("decode machine data: %w", err) - } - - err = extract.Extract(bytes.NewReader(decoded), machineDir) - if err != nil { - return fmt.Errorf("extract machine data: %w", err) + if err := extractExportDir(machineDir, exportConfig.Machine.Data, "machine"); err != nil { + return err } // exchange config @@ -240,20 +228,8 @@ func (cmd *ImportCmd) importProvider( return fmt.Errorf("get provider dir: %w", err) } - // #nosec G301 -- TODO Consider using a more secure permission setting and ownership if needed. - err = os.MkdirAll(providerDir, 0o755) - if err != nil { - return fmt.Errorf("create provider dir: %w", err) - } - - decoded, err := base64.RawStdEncoding.DecodeString(exportConfig.Provider.Data) - if err != nil { - return fmt.Errorf("decode provider data: %w", err) - } - - err = extract.Extract(bytes.NewReader(decoded), providerDir) - if err != nil { - return fmt.Errorf("extract provider data: %w", err) + if err := extractExportDir(providerDir, exportConfig.Provider.Data, "provider"); err != nil { + return err } // exchange config @@ -269,23 +245,49 @@ func (cmd *ImportCmd) importProvider( return fmt.Errorf("save provider config: %w", err) } - // add provider options - if exportConfig.Provider.Config != nil { - if devsyConfig.Current().Providers == nil { - devsyConfig.Current().Providers = map[string]*config.ProviderConfig{} - } - - devsyConfig.Current().Providers[cmd.ProviderID] = exportConfig.Provider.Config - err = config.SaveConfig(devsyConfig) - if err != nil { - return fmt.Errorf("save devsy config: %w", err) - } + if err := cmd.applyProviderOptions(devsyConfig, exportConfig); err != nil { + return err } log.Infof("imported provider: providerId=%s", cmd.ProviderID) return nil } +func (cmd *ImportCmd) applyProviderOptions( + devsyConfig *config.Config, + exportConfig *provider.ExportConfig, +) error { + if exportConfig.Provider.Config == nil { + return nil + } + if devsyConfig.Current().Providers == nil { + devsyConfig.Current().Providers = map[string]*config.ProviderConfig{} + } + + devsyConfig.Current().Providers[cmd.ProviderID] = exportConfig.Provider.Config + if err := config.SaveConfig(devsyConfig); err != nil { + return fmt.Errorf("save devsy config: %w", err) + } + return nil +} + +func extractExportDir(dir, data, label string) error { + // #nosec G301 -- TODO Consider using a more secure permission setting and ownership if needed. + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("create %s dir: %w", label, err) + } + + decoded, err := base64.RawStdEncoding.DecodeString(data) + if err != nil { + return fmt.Errorf("decode %s data: %w", label, err) + } + + if err := extract.Extract(bytes.NewReader(decoded), dir); err != nil { + return fmt.Errorf("extract %s data: %w", label, err) + } + return nil +} + func (cmd *ImportCmd) checkForConflictingIDs( ctx context.Context, exportConfig *provider.ExportConfig, @@ -296,25 +298,43 @@ func (cmd *ImportCmd) checkForConflictingIDs( return fmt.Errorf("error listing workspaces: %w", err) } - // check for workspace duplicate - if exportConfig.Workspace != nil { - for _, workspace := range workspaces { - if workspace.ID == cmd.WorkspaceID { - return fmt.Errorf( - "existing workspace with id %s found, use --workspace-id to override the workspace id", - cmd.WorkspaceID, - ) - } else if workspace.UID == exportConfig.Workspace.UID { - return fmt.Errorf( - "existing workspace %s with uid %s found, use --workspace-id to override the workspace id", - workspace.ID, - workspace.UID, - ) - } + if err := cmd.checkWorkspaceConflict(exportConfig, workspaces); err != nil { + return err + } + if err := cmd.checkMachineConflict(exportConfig, devsyConfig); err != nil { + return err + } + return cmd.checkProviderConflict(exportConfig, devsyConfig) +} + +func (cmd *ImportCmd) checkWorkspaceConflict( + exportConfig *provider.ExportConfig, + workspaces []*provider.Workspace, +) error { + if exportConfig.Workspace == nil { + return nil + } + for _, workspace := range workspaces { + if workspace.ID == cmd.WorkspaceID { + return fmt.Errorf( + "existing workspace with id %s found, use --workspace-id to override the workspace id", + cmd.WorkspaceID, + ) + } else if workspace.UID == exportConfig.Workspace.UID { + return fmt.Errorf( + "existing workspace %s with uid %s found, use --workspace-id to override the workspace id", + workspace.ID, + workspace.UID, + ) } } + return nil +} - // check if machine already exists +func (cmd *ImportCmd) checkMachineConflict( + exportConfig *provider.ExportConfig, + devsyConfig *config.Config, +) error { if !cmd.MachineReuse && exportConfig.Machine != nil { if provider.MachineExists(devsyConfig.DefaultContext, cmd.MachineID) { return fmt.Errorf( @@ -324,8 +344,13 @@ func (cmd *ImportCmd) checkForConflictingIDs( ) } } + return nil +} - // check if provider already exists +func (cmd *ImportCmd) checkProviderConflict( + exportConfig *provider.ExportConfig, + devsyConfig *config.Config, +) error { if !cmd.ProviderReuse && exportConfig.Provider != nil { if provider.ProviderExists(devsyConfig.DefaultContext, cmd.ProviderID) { return fmt.Errorf( @@ -335,6 +360,5 @@ func (cmd *ImportCmd) checkForConflictingIDs( ) } } - return nil } diff --git a/cmd/workspace/list.go b/cmd/workspace/list.go index dd04d13c9..92fd16a88 100644 --- a/cmd/workspace/list.go +++ b/cmd/workspace/list.go @@ -12,6 +12,7 @@ import ( cliflags "github.com/devsy-org/devsy/pkg/flags" "github.com/devsy-org/devsy/pkg/flags/names" "github.com/devsy-org/devsy/pkg/output" + "github.com/devsy-org/devsy/pkg/provider" "github.com/devsy-org/devsy/pkg/table" "github.com/devsy-org/devsy/pkg/telemetry" "github.com/devsy-org/devsy/pkg/workspace" @@ -65,47 +66,60 @@ func (cmd *ListCmd) Run(ctx context.Context) error { } switch mode { case output.ModeJSON: - sort.SliceStable(workspaces, func(i, j int) bool { - return workspaces[i].LastUsedTimestamp.Unix() > workspaces[j].LastUsedTimestamp.Unix() - }) - out, err := json.Marshal(workspaces) - if err != nil { + if err := printJSONWorkspaces(workspaces); err != nil { return err } - fmt.Print(string(out)) case output.ModePlain: - tableEntries := [][]string{} - sort.SliceStable(workspaces, func(i, j int) bool { - return workspaces[i].LastUsedTimestamp.Unix() > workspaces[j].LastUsedTimestamp.Unix() - }) - for _, entry := range workspaces { - name := entry.ID - if entry.IsPro() && entry.Pro.DisplayName != "" && entry.ID != entry.Pro.DisplayName { - name = fmt.Sprintf("%s (%s)", entry.Pro.DisplayName, entry.ID) - } - tableEntries = append(tableEntries, []string{ - name, - entry.Source.String(), - entry.Machine.ID, - entry.Provider.Name, - entry.IDE.Name, - time.Since(entry.LastUsedTimestamp.Time).Round(1 * time.Second).String(), - time.Since(entry.CreationTimestamp.Time).Round(1 * time.Second).String(), - fmt.Sprintf("%t", entry.IsPro()), - }) - } - - table.Print([]string{ - "Name", - "Source", - "Machine", - "Provider", - "IDE", - "Last Used", - "Age", - "Pro", - }, tableEntries) + printPlainWorkspaces(workspaces) } return nil } + +func sortWorkspacesByLastUsed(workspaces []*provider.Workspace) { + sort.SliceStable(workspaces, func(i, j int) bool { + return workspaces[i].LastUsedTimestamp.Unix() > workspaces[j].LastUsedTimestamp.Unix() + }) +} + +func printJSONWorkspaces(workspaces []*provider.Workspace) error { + sortWorkspacesByLastUsed(workspaces) + out, err := json.Marshal(workspaces) + if err != nil { + return err + } + fmt.Print(string(out)) //nolint:forbidigo // CLI stdout output + return nil +} + +func printPlainWorkspaces(workspaces []*provider.Workspace) { + sortWorkspacesByLastUsed(workspaces) + tableEntries := [][]string{} + for _, entry := range workspaces { + name := entry.ID + if entry.IsPro() && entry.Pro.DisplayName != "" && entry.ID != entry.Pro.DisplayName { + name = fmt.Sprintf("%s (%s)", entry.Pro.DisplayName, entry.ID) + } + tableEntries = append(tableEntries, []string{ + name, + entry.Source.String(), + entry.Machine.ID, + entry.Provider.Name, + entry.IDE.Name, + time.Since(entry.LastUsedTimestamp.Time).Round(1 * time.Second).String(), + time.Since(entry.CreationTimestamp.Time).Round(1 * time.Second).String(), + fmt.Sprintf("%t", entry.IsPro()), + }) + } + + table.Print([]string{ + "Name", + "Source", + "Machine", + "Provider", + "IDE", + "Last Used", + "Age", + "Pro", + }, tableEntries) +} diff --git a/cmd/workspace/logs.go b/cmd/workspace/logs.go index ccc9c4326..4b8dac74c 100644 --- a/cmd/workspace/logs.go +++ b/cmd/workspace/logs.go @@ -111,31 +111,35 @@ func (cmd *LogsCmd) Run(ctx context.Context, args []string) error { }) }, func(ctx context.Context, stdout, stdin *os.File) error { - sshClient, err := ssh.StdioClientWithUser(stdout, stdin, "", false) - if err != nil { - return err - } - defer func() { _ = sshClient.Close() }() - - session, err := sshClient.NewSession() - if err != nil { - return err - } - defer func() { _ = session.Close() }() - - agentCommand := fmt.Sprintf( - "%q internal agent workspace logs --context %q --id %q", - client.AgentPath(), - client.Context(), - client.Workspace(), - ) - if log.DebugEnabled() { - agentCommand += " --debug" - } - - session.Stdout = os.Stdout - session.Stderr = os.Stderr - return session.Run(agentCommand) + return runLogsSession(stdout, stdin, client) }, ) } + +func runLogsSession(stdout, stdin *os.File, client clientpkg.WorkspaceClient) error { + sshClient, err := ssh.StdioClientWithUser(stdout, stdin, "", false) + if err != nil { + return err + } + defer func() { _ = sshClient.Close() }() + + session, err := sshClient.NewSession() + if err != nil { + return err + } + defer func() { _ = session.Close() }() + + agentCommand := fmt.Sprintf( + "%q internal agent workspace logs --context %q --id %q", + client.AgentPath(), + client.Context(), + client.Workspace(), + ) + if log.DebugEnabled() { + agentCommand += " --debug" + } + + session.Stdout = os.Stdout + session.Stderr = os.Stderr + return session.Run(agentCommand) +} diff --git a/cmd/workspace/ssh.go b/cmd/workspace/ssh.go index c7f6d08d1..08f30ac13 100644 --- a/cmd/workspace/ssh.go +++ b/cmd/workspace/ssh.go @@ -159,17 +159,7 @@ func (cmd *SSHCmd) Run( devsyConfig *config.Config, client client2.BaseWorkspaceClient, ) error { - // add ssh keys to agent - if devsyConfig.ContextOption(config.ContextOptionSSHAgentForwarding) == config.BoolTrue && - devsyConfig.ContextOption(config.ContextOptionSSHAddPrivateKeys) == config.BoolTrue { - log.Debug( - "adding ssh keys to agent, disable via 'devsy context set -o SSH_ADD_PRIVATE_KEYS=false'", - ) - err := devssh.AddPrivateKeysToAgent(ctx) - if err != nil { - log.Debugf("Error adding private keys to ssh-agent: %v", err) - } - } + cmd.addPrivateKeysToAgentIfEnabled(ctx, devsyConfig) // get user if cmd.User == "" { @@ -205,6 +195,19 @@ func (cmd *SSHCmd) Run( return nil } +func (cmd *SSHCmd) addPrivateKeysToAgentIfEnabled(ctx context.Context, devsyConfig *config.Config) { + if devsyConfig.ContextOption(config.ContextOptionSSHAgentForwarding) != config.BoolTrue || + devsyConfig.ContextOption(config.ContextOptionSSHAddPrivateKeys) != config.BoolTrue { + return + } + log.Debug( + "adding ssh keys to agent, disable via 'devsy context set -o SSH_ADD_PRIVATE_KEYS=false'", + ) + if err := devssh.AddPrivateKeysToAgent(ctx); err != nil { + log.Debugf("Error adding private keys to ssh-agent: %v", err) + } +} + func (cmd *SSHCmd) execute(ctx context.Context, args []string) error { devsyConfig, err := config.LoadConfig(cmd.Context, cmd.Provider) if err != nil { @@ -242,43 +245,16 @@ func (cmd *SSHCmd) jumpContainerTailscale( defer func() { _ = toolSSHClient.Close() }() defer func() { _ = sshClient.Close() }() - // Forward ports if specified - if len(cmd.ForwardPorts) > 0 { - return cmd.forwardPorts(ctx, toolSSHClient) + // Forward or reverse-forward ports if specified + if handled, err := cmd.forwardPortsIfRequested(ctx, toolSSHClient); handled { + return err } - // Reverse forward ports if specified - if len(cmd.ReverseForwardPorts) > 0 && !cmd.GPGAgentForwarding { - return cmd.reverseForwardPorts(ctx, toolSSHClient) - } - - if cmd.StartServices { - go func() { - err = clientimplementation.StartServicesDaemon( - ctx, - clientimplementation.StartServicesDaemonOptions{ - DevsyConfig: devsyConfig, - Client: client, - SSHClient: toolSSHClient, - User: cmd.User, - ForwardPorts: false, - ExtraPorts: nil, - }, - ) - if err != nil { - log.Errorf("Error starting services: %v", err) - } - }() - } + cmd.startServicesDaemon(ctx, devsyConfig, client, toolSSHClient) // Handle GPG agent forwarding - if cmd.GPGAgentForwarding || - devsyConfig.ContextOptionBool(config.ContextOptionGPGAgentForwarding) { - if gpg.IsGpgTunnelRunning(ctx, cmd.User, toolSSHClient) { - log.Debugf("[GPG] exporting already running, skipping") - } else if err := cmd.setupGPGAgent(ctx, toolSSHClient); err != nil { - return err - } + if err := cmd.maybeSetupGPGAgent(ctx, devsyConfig, toolSSHClient); err != nil { + return err } // Handle ssh stdio mode @@ -306,6 +282,64 @@ func (cmd *SSHCmd) jumpContainerTailscale( ) } +// forwardPortsIfRequested handles -L/-R forwarding when requested. The returned +// bool reports whether forwarding took over (the caller should return err). +func (cmd *SSHCmd) forwardPortsIfRequested( + ctx context.Context, + sshClient *ssh.Client, +) (bool, error) { + if len(cmd.ForwardPorts) > 0 { + return true, cmd.forwardPorts(ctx, sshClient) + } + if len(cmd.ReverseForwardPorts) > 0 && !cmd.GPGAgentForwarding { + return true, cmd.reverseForwardPorts(ctx, sshClient) + } + return false, nil +} + +func (cmd *SSHCmd) startServicesDaemon( + ctx context.Context, + devsyConfig *config.Config, + client client2.DaemonClient, + sshClient *ssh.Client, +) { + if !cmd.StartServices { + return + } + go func() { + err := clientimplementation.StartServicesDaemon( + ctx, + clientimplementation.StartServicesDaemonOptions{ + DevsyConfig: devsyConfig, + Client: client, + SSHClient: sshClient, + User: cmd.User, + ForwardPorts: false, + ExtraPorts: nil, + }, + ) + if err != nil { + log.Errorf("Error starting services: %v", err) + } + }() +} + +func (cmd *SSHCmd) maybeSetupGPGAgent( + ctx context.Context, + devsyConfig *config.Config, + sshClient *ssh.Client, +) error { + if !cmd.GPGAgentForwarding && + !devsyConfig.ContextOptionBool(config.ContextOptionGPGAgentForwarding) { + return nil + } + if gpg.IsGpgTunnelRunning(ctx, cmd.User, sshClient) { + log.Debugf("[GPG] exporting already running, skipping") + return nil + } + return cmd.setupGPGAgent(ctx, sshClient) +} + func (cmd *SSHCmd) startProxyTunnel( ctx context.Context, devsyConfig *config.Config, @@ -497,83 +531,25 @@ func (cmd *SSHCmd) startTunnel( workspaceClient client2.BaseWorkspaceClient, ) error { // check if we should forward ports - if len(cmd.ForwardPorts) > 0 { - return cmd.forwardPorts(ctx, containerClient) - } - - // check if we should reverse forward ports - if len(cmd.ReverseForwardPorts) > 0 && !cmd.GPGAgentForwarding { - return cmd.reverseForwardPorts(ctx, containerClient) + if handled, err := cmd.forwardPortsIfRequested(ctx, containerClient); handled { + return err } - if cmd.StartServices { - configureDockerCredentials := devsyConfig.ContextOption( - config.ContextOptionSSHInjectDockerCredentials, - ) == config.BoolTrue - configureGitCredentials := devsyConfig.ContextOption( - config.ContextOptionSSHInjectGitCredentials, - ) == config.BoolTrue - configureGitSSHSignatureHelper := devsyConfig.ContextOption( - config.ContextOptionGitSSHSignatureForwarding, - ) == config.BoolTrue + cmd.startTunnelServices(ctx, devsyConfig, containerClient, workspaceClient) - go cmd.startServices( - ctx, - devsyConfig, - containerClient, - workspaceClient.WorkspaceConfig(), - configureDockerCredentials, - configureGitCredentials, - configureGitSSHSignatureHelper, - cmd.GitSSHSigningKey, - ) - } // start ssh writer := log.Writer(log.LevelInfo) defer func() { _ = writer.Close() }() // check if we should do gpg agent forwarding - if cmd.GPGAgentForwarding || - devsyConfig.ContextOptionBool(config.ContextOptionGPGAgentForwarding) { - // Check if a forwarding is already enabled and running, in that case - // we skip the forwarding and keep using the original one - if gpg.IsGpgTunnelRunning(ctx, cmd.User, containerClient) { - log.Debugf("[GPG] exporting already running, skipping") - } else { - err := cmd.setupGPGAgent(ctx, containerClient) - if err != nil { - return err - } - } + if err := cmd.maybeSetupGPGAgent(ctx, devsyConfig, containerClient); err != nil { + return err } workdir := resolveWorkdir(cmd.WorkDir, workspaceClient) log.Debugf("Run outer container tunnel") - commandArgs := []string{ - config.ContainerDevsyHelperLocation, - "internal", - "ssh-server", - names.Flag(names.TrackActivity), - names.Flag(names.Stdio), - names.Flag(names.Workdir), - workdir, - } - if cmd.ReuseSSHAuthSock != "" { - log.Debug("Reusing SSH_AUTH_SOCK") - commandArgs = append( - commandArgs, - names.Flag(names.ReuseSSHAuthSock), - cmd.ReuseSSHAuthSock, - ) - } - if cmd.Debug { - commandArgs = append(commandArgs, names.Flag(names.Debug)) - } - command := shellescape.QuoteCommand(commandArgs) - if cmd.User != "" && cmd.User != "root" { - command = shellescape.QuoteCommand([]string{"su", "-c", command, cmd.User}) - } + command := cmd.buildSSHServerCommand(workdir) envVars, err := cmd.retrieveEnVars() if err != nil { @@ -618,6 +594,65 @@ func (cmd *SSHCmd) startTunnel( }) } +func (cmd *SSHCmd) startTunnelServices( + ctx context.Context, + devsyConfig *config.Config, + containerClient *ssh.Client, + workspaceClient client2.BaseWorkspaceClient, +) { + if !cmd.StartServices { + return + } + configureDockerCredentials := devsyConfig.ContextOption( + config.ContextOptionSSHInjectDockerCredentials, + ) == config.BoolTrue + configureGitCredentials := devsyConfig.ContextOption( + config.ContextOptionSSHInjectGitCredentials, + ) == config.BoolTrue + configureGitSSHSignatureHelper := devsyConfig.ContextOption( + config.ContextOptionGitSSHSignatureForwarding, + ) == config.BoolTrue + + go cmd.startServices( + ctx, + devsyConfig, + containerClient, + workspaceClient.WorkspaceConfig(), + configureDockerCredentials, + configureGitCredentials, + configureGitSSHSignatureHelper, + cmd.GitSSHSigningKey, + ) +} + +func (cmd *SSHCmd) buildSSHServerCommand(workdir string) string { + commandArgs := []string{ + config.ContainerDevsyHelperLocation, + "internal", + "ssh-server", + names.Flag(names.TrackActivity), + names.Flag(names.Stdio), + names.Flag(names.Workdir), + workdir, + } + if cmd.ReuseSSHAuthSock != "" { + log.Debug("Reusing SSH_AUTH_SOCK") + commandArgs = append( + commandArgs, + names.Flag(names.ReuseSSHAuthSock), + cmd.ReuseSSHAuthSock, + ) + } + if cmd.Debug { + commandArgs = append(commandArgs, names.Flag(names.Debug)) + } + command := shellescape.QuoteCommand(commandArgs) + if cmd.User != "" && cmd.User != "root" { + command = shellescape.QuoteCommand([]string{"su", "-c", command, cmd.User}) + } + return command +} + func resolveWorkdir( workdir string, workspaceClient client2.BaseWorkspaceClient, diff --git a/cmd/workspace/stop.go b/cmd/workspace/stop.go index dd0fd58df..f7a41ef5c 100644 --- a/cmd/workspace/stop.go +++ b/cmd/workspace/stop.go @@ -11,6 +11,7 @@ import ( "github.com/devsy-org/devsy/pkg/config" "github.com/devsy-org/devsy/pkg/ide/opener" "github.com/devsy-org/devsy/pkg/log" + "github.com/devsy-org/devsy/pkg/provider" workspace2 "github.com/devsy-org/devsy/pkg/workspace" "github.com/spf13/cobra" ) @@ -131,17 +132,7 @@ func (cmd *StopCmd) stopSingleMachine( return false, fmt.Errorf("list workspaces: %w", err) } - // loop workspaces - foundOther := false - for _, workspace := range workspaces { - if workspace.ID == client.Workspace() || workspace.Machine.ID != singleMachineName { - continue - } - - foundOther = true - break - } - if foundOther { + if otherWorkspaceUsesMachine(workspaces, client.Workspace(), singleMachineName) { return false, nil } @@ -163,3 +154,16 @@ func (cmd *StopCmd) stopSingleMachine( log.Infof("stopped workspace: workspace=%s", client.Workspace()) return true, nil } + +func otherWorkspaceUsesMachine( + workspaces []*provider.Workspace, + currentWorkspaceID, machineName string, +) bool { + for _, ws := range workspaces { + if ws.ID == currentWorkspaceID || ws.Machine.ID != machineName { + continue + } + return true + } + return false +} diff --git a/cmd/workspace/troubleshoot.go b/cmd/workspace/troubleshoot.go index e292a0d0c..1d18eb604 100644 --- a/cmd/workspace/troubleshoot.go +++ b/cmd/workspace/troubleshoot.go @@ -51,114 +51,138 @@ func NewTroubleshootCmd(flags *flags.GlobalFlags) *cobra.Command { return troubleshootCmd } -func (cmd *TroubleshootCmd) Run(ctx context.Context, args []string) { - var info struct { - CLIVersion string - Config *config.Config - Providers map[string]provider.ProviderWithDefault - DevsyProInstances []DevsyProInstance - Workspace *pkgprovider.Workspace - WorkspaceStatus client.Status - WorkspaceTroubleshoot *managementv1.DevsyWorkspaceInstanceTroubleshoot - DaemonStatus *daemon.Status - - Errors []PrintableError `json:",omitempty"` +type troubleshootInfo struct { + CLIVersion string + Config *config.Config + Providers map[string]provider.ProviderWithDefault + DevsyProInstances []DevsyProInstance + Workspace *pkgprovider.Workspace + WorkspaceStatus client.Status + WorkspaceTroubleshoot *managementv1.DevsyWorkspaceInstanceTroubleshoot + DaemonStatus *daemon.Status + + Errors []PrintableError `json:",omitempty"` +} + +func (info *troubleshootInfo) addErr(context string, err error) { + info.Errors = append(info.Errors, PrintableError{fmt.Errorf("%s: %w", context, err)}) +} + +func printTroubleshootInfo(info *troubleshootInfo) { + out, err := json.MarshalIndent(info, "", " ") + if err == nil { + fmt.Print(string(out)) //nolint:forbidigo // CLI stdout output + } else { + fmt.Print(err) //nolint:forbidigo // CLI stdout output + fmt.Print(*info) //nolint:forbidigo // CLI stdout output } - info.CLIVersion = version.GetVersion() +} + +func (cmd *TroubleshootCmd) Run(ctx context.Context, args []string) { + info := &troubleshootInfo{CLIVersion: version.GetVersion()} // Print on every exit path, including panics. - defer func() { - out, err := json.MarshalIndent(info, "", " ") - if err == nil { - fmt.Print(string(out)) - } else { - fmt.Print(err) - fmt.Print(info) - } - }() + defer printTroubleshootInfo(info) // Collect as much as possible — partial info beats no info, so do not // return early on errors except where downstream steps require the result. var err error info.Config, err = config.LoadConfig(cmd.Context, cmd.Provider) if err != nil { - info.Errors = append(info.Errors, PrintableError{fmt.Errorf("load config: %w", err)}) + info.addErr("load config", err) // Without the devsy config no further troubleshooting is possible. return } info.Providers, err = collectProviders(info.Config) if err != nil { - info.Errors = append(info.Errors, PrintableError{fmt.Errorf("collect providers: %w", err)}) + info.addErr("collect providers", err) } info.DevsyProInstances, err = collectPlatformInfo(info.Config) if err != nil { - info.Errors = append( - info.Errors, - PrintableError{fmt.Errorf("collect platform info: %w", err)}, - ) + info.addErr("collect platform info", err) } + cmd.collectWorkspaceInfo(ctx, info, args) +} + +func (cmd *TroubleshootCmd) collectWorkspaceInfo( + ctx context.Context, + info *troubleshootInfo, + args []string, +) { workspaceClient, err := workspace.Get(ctx, workspace.GetOptions{ DevsyConfig: info.Config, Args: args, Owner: cmd.Owner, }) - if err == nil { - info.Workspace = workspaceClient.WorkspaceConfig() - info.WorkspaceStatus, err = workspaceClient.Status(ctx, client.StatusOptions{}) + if err != nil { + info.addErr("get workspace", err) + return + } + + info.Workspace = workspaceClient.WorkspaceConfig() + info.WorkspaceStatus, err = workspaceClient.Status(ctx, client.StatusOptions{}) + if err != nil { + info.addErr("workspace status", err) + } + + if info.Workspace.Pro != nil { + info.WorkspaceTroubleshoot, err = collectWorkspaceProTroubleshoot( + ctx, info.Config, info.Workspace, info.DevsyProInstances, + ) if err != nil { - info.Errors = append( - info.Errors, - PrintableError{fmt.Errorf("workspace status: %w", err)}, - ) + info.addErr("collect pro workspace info", err) } + } - if info.Workspace.Pro != nil { - // Multiple pro instances may be configured; locate the one that - // owns this workspace. - var proInstance DevsyProInstance - - for _, instance := range info.DevsyProInstances { - if instance.ProviderName == info.Workspace.Provider.Name { - proInstance = instance - break - } - } - - if proInstance.ProviderName != "" { - info.WorkspaceTroubleshoot, err = collectProWorkspaceInfo( - ctx, - info.Config, - proInstance.Host, - info.Workspace.UID, - info.Workspace.Pro.Project, - ) - if err != nil { - info.Errors = append( - info.Errors, - PrintableError{fmt.Errorf("collect pro workspace info: %w", err)}, - ) - } - } + info.DaemonStatus, err = collectDaemonStatus(ctx, workspaceClient) + if err != nil { + info.addErr("get daemon status", err) + } +} + +// collectWorkspaceProTroubleshoot locates the pro instance that owns the given +// workspace and retrieves its troubleshooting info. It returns (nil, nil) when +// no matching pro instance is configured. +func collectWorkspaceProTroubleshoot( + ctx context.Context, + devsyConfig *config.Config, + ws *pkgprovider.Workspace, + proInstances []DevsyProInstance, +) (*managementv1.DevsyWorkspaceInstanceTroubleshoot, error) { + // Multiple pro instances may be configured; locate the one that owns this workspace. + var proInstance DevsyProInstance + for _, instance := range proInstances { + if instance.ProviderName == ws.Provider.Name { + proInstance = instance + break } - } else { - info.Errors = append(info.Errors, PrintableError{fmt.Errorf("get workspace: %w", err)}) } + if proInstance.ProviderName == "" { + return nil, nil + } + + return collectProWorkspaceInfo(ctx, devsyConfig, proInstance.Host, ws.UID, ws.Pro.Project) +} + +// collectDaemonStatus returns the local daemon status when the client is a +// daemon client, or (nil, nil) otherwise. +func collectDaemonStatus( + ctx context.Context, + workspaceClient client.BaseWorkspaceClient, +) (*daemon.Status, error) { daemonClient, ok := workspaceClient.(client.DaemonClient) - if ok { - status, err := daemon.NewLocalClient(daemonClient.Provider()).Status(ctx, true) - if err != nil { - info.Errors = append( - info.Errors, - PrintableError{fmt.Errorf("get daemon status: %w", err)}, - ) - } else { - info.DaemonStatus = &status - } + if !ok { + return nil, nil + } + status, err := daemon.NewLocalClient(daemonClient.Provider()).Status(ctx, true) + if err != nil { + return nil, err } + return &status, nil } // collectProWorkspaceInfo collects troubleshooting information for a Devsy Pro instance. diff --git a/cmd/workspace/up/up.go b/cmd/workspace/up/up.go index 45add3c04..fcbf1e617 100644 --- a/cmd/workspace/up/up.go +++ b/cmd/workspace/up/up.go @@ -138,8 +138,8 @@ func RunFromOptions(ctx context.Context, g *flags.GlobalFlags, opts Options) err if err != nil { return fmt.Errorf("prepare workspace client: %w", err) } - if cmd.ExtraDevContainerPath != "" && client.Provider() != "docker" { - return fmt.Errorf("extra devcontainer file is only supported with local provider") + if err := cmd.checkExtraDevContainerProvider(client); err != nil { + return err } telemetry.FromContext(ctx).SetClient(client) if err := cmd.Run(ctx, devsyConfig, client, args); err != nil { @@ -231,6 +231,13 @@ func (cmd *UpCmd) Run( }) } +func (cmd *UpCmd) checkExtraDevContainerProvider(client client2.BaseWorkspaceClient) error { + if cmd.ExtraDevContainerPath != "" && client.Provider() != "docker" { + return fmt.Errorf("extra devcontainer file is only supported with local provider") + } + return nil +} + func (cmd *UpCmd) applyConfig(devsyConfig *config.Config) { if devsyConfig.ContextOptionBool(config.ContextOptionSSHStrictHostKeyChecking) { cmd.StrictHostKeyChecking = true @@ -353,8 +360,8 @@ func (cmd *UpCmd) execute(cobraCmd *cobra.Command, args []string) error { if err != nil { return fmt.Errorf("prepare workspace client: %w", err) } - if cmd.ExtraDevContainerPath != "" && client.Provider() != "docker" { - return fmt.Errorf("extra devcontainer file is only supported with local provider") + if err := cmd.checkExtraDevContainerProvider(client); err != nil { + return err } telemetry.FromContext(cobraCmd.Context()).SetClient(client) diff --git a/cmd/workspace/up/up_validate.go b/cmd/workspace/up/up_validate.go index 9e5e75f99..08bd57913 100644 --- a/cmd/workspace/up/up_validate.go +++ b/cmd/workspace/up/up_validate.go @@ -30,10 +30,8 @@ func (cmd *UpCmd) validate() error { if err := config2.ValidateIDLabels(cmd.IDLabels); err != nil { return err } - if cmd.DefaultUserEnvProbe != "" { - if _, err := config2.NewUserEnvProbe(cmd.DefaultUserEnvProbe); err != nil { - return err - } + if err := cmd.validateUserEnvProbe(); err != nil { + return err } if err := cmd.resolveExtraDevContainerPath(); err != nil { return err @@ -48,6 +46,16 @@ func (cmd *UpCmd) validate() error { return validateRemoteUserUID(cmd.UpdateRemoteUserUIDDefault) } +func (cmd *UpCmd) validateUserEnvProbe() error { + if cmd.DefaultUserEnvProbe == "" { + return nil + } + if _, err := config2.NewUserEnvProbe(cmd.DefaultUserEnvProbe); err != nil { + return err + } + return nil +} + func (cmd *UpCmd) resolveExtraDevContainerPath() error { if cmd.ExtraDevContainerPath == "" { return nil diff --git a/e2e/framework/server_utils.go b/e2e/framework/server_utils.go index ba4ab9e24..2dc09a426 100644 --- a/e2e/framework/server_utils.go +++ b/e2e/framework/server_utils.go @@ -65,24 +65,32 @@ func getIP() string { } for _, addr := range addrs { - switch v := addr.(type) { - case *net.IPAddr: - if v.IP.To4() != nil { - if v.IP.DefaultMask().String() == "ffffff00" || - v.IP.DefaultMask().String() == "ff000000" { - return v.IP.String() - } - } - case *net.IPNet: - if v.IP.To4() != nil { - if v.IP.DefaultMask().String() == "ffffff00" || - v.IP.DefaultMask().String() == "ff000000" { - return v.IP.String() - } - } + if ip := matchIPv4(addr); ip != "" { + return ip } } } return "0.0.0.0" } + +func matchIPv4(addr net.Addr) string { + var ip net.IP + switch v := addr.(type) { + case *net.IPAddr: + ip = v.IP + case *net.IPNet: + ip = v.IP + default: + return "" + } + + if ip.To4() == nil { + return "" + } + mask := ip.DefaultMask().String() + if mask == "ffffff00" || mask == "ff000000" { + return ip.String() + } + return "" +} diff --git a/e2e/tests/up/helper.go b/e2e/tests/up/helper.go index e25bf48b6..d7d073e5f 100644 --- a/e2e/tests/up/helper.go +++ b/e2e/tests/up/helper.go @@ -107,40 +107,50 @@ func (dtc *dockerTestContext) findWorkspaceContainer( func findMessage(reader io.Reader, message string) error { scan := scanner.NewScanner(reader) for scan.Scan() { - if line := scan.Bytes(); len(line) > 0 { - lineObject := &logLine{} - if err := json.Unmarshal(line, lineObject); err == nil { - msg := lineObject.Message - if msg == "" { - msg = lineObject.Msg - } - if strings.Contains(msg, message) { - return nil - } - // Agent JSON may be embedded in the parent's error chain. - // Parse any nested JSON lines within the msg to resolve - // double-escaped quotes. - for part := range strings.SplitSeq(msg, "\n") { - part = strings.TrimSpace(part) - if len(part) > 0 && part[0] == '{' { - inner := &logLine{} - if json.Unmarshal([]byte(part), inner) == nil { - innerMsg := inner.Message - if innerMsg == "" { - innerMsg = inner.Msg - } - if strings.Contains(innerMsg, message) { - return nil - } - } - } - } - } + line := scan.Bytes() + if len(line) == 0 { + continue + } + lineObject := &logLine{} + if json.Unmarshal(line, lineObject) != nil { + continue + } + if lineMatchesMessage(lineObject, message) { + return nil } } return fmt.Errorf("couldn't find message %q in log", message) } +func (l *logLine) message() string { + if l.Message != "" { + return l.Message + } + return l.Msg +} + +func lineMatchesMessage(lineObject *logLine, message string) bool { + msg := lineObject.message() + if strings.Contains(msg, message) { + return true + } + // Agent JSON may be embedded in the parent's error chain. + // Parse any nested JSON lines within the msg to resolve + // double-escaped quotes. + for part := range strings.SplitSeq(msg, "\n") { + part = strings.TrimSpace(part) + if len(part) == 0 || part[0] != '{' { + continue + } + inner := &logLine{} + if json.Unmarshal([]byte(part), inner) == nil && + strings.Contains(inner.message(), message) { + return true + } + } + return false +} + func verifyLogStream(reader io.Reader) error { scan := scanner.NewScanner(reader) for scan.Scan() { diff --git a/hack/merge_mac_metadata/main.go b/hack/merge_mac_metadata/main.go index eb250ce73..6ed31e62a 100644 --- a/hack/merge_mac_metadata/main.go +++ b/hack/merge_mac_metadata/main.go @@ -60,27 +60,30 @@ func mergeFileEntries(paths []string) (base map[string]any, files []any, err err if !ok { continue } - for _, e := range entries { - // Defensive: electron-builder always emits maps; skip non-map entries - // (prior behavior appended raw entries via variadic spread). - entry, ok := e.(map[string]any) - if !ok { - continue - } - url, _ := entry["url"].(string) - if url == "" { - files = append(files, entry) - continue - } - if idx, exists := seen[url]; exists { - files[idx] = entry // last-write-wins - continue - } + files = appendEntries(files, seen, entries) + } + return base, files, nil +} + +func appendEntries(files []any, seen map[string]int, entries []any) []any { + for _, e := range entries { + // Defensive: electron-builder always emits maps; skip non-map entries + // (prior behavior appended raw entries via variadic spread). + entry, ok := e.(map[string]any) + if !ok { + continue + } + url, _ := entry["url"].(string) + if idx, exists := seen[url]; url != "" && exists { + files[idx] = entry // last-write-wins + continue + } + if url != "" { seen[url] = len(files) - files = append(files, entry) } + files = append(files, entry) } - return base, files, nil + return files } // applyTopLevelFromFirst sets the top-level path/sha512/size from the diff --git a/hack/pro/main.go b/hack/pro/main.go index 6e3e65917..c5746fa8c 100644 --- a/hack/pro/main.go +++ b/hack/pro/main.go @@ -26,36 +26,58 @@ func main() { basePath = os.Args[2] } + checksumMap := buildChecksumMap(basePath) + + sourceFile, ok := os.LookupEnv("SOURCE_FILE") + absPath := loadProviderSource(sourceFile, ok) + + replaced := strings.ReplaceAll(provider, "##VERSION##", os.Args[1]) + replaced = applyChecksums(replaced, checksumMap, os.Getenv("PARTIAL") == "true") + + if !ok { + fmt.Println(replaced) + return + } + + // #nosec G306,G703 -- TODO Consider using a more secure permission setting and ownership if needed. + if err := os.WriteFile(absPath, []byte(replaced), 0o644); err != nil { + panic(err) + } +} + +func buildChecksumMap(basePath string) map[string]string { bin := config.BinaryName - checksumMap := map[string]string{ + return map[string]string{ filepath.Join(basePath, bin+"-linux-amd64"): "##CHECKSUM_LINUX_AMD64##", filepath.Join(basePath, bin+"-linux-arm64"): "##CHECKSUM_LINUX_ARM64##", filepath.Join(basePath, bin+"-darwin-amd64"): "##CHECKSUM_DARWIN_AMD64##", filepath.Join(basePath, bin+"-darwin-arm64"): "##CHECKSUM_DARWIN_ARM64##", filepath.Join(basePath, bin+"-windows-amd64.exe"): "##CHECKSUM_WINDOWS_AMD64##", } +} - partial := os.Getenv("PARTIAL") == "true" - sourceFile, ok := os.LookupEnv("SOURCE_FILE") - absPath := "" - - if ok { - var err error - - absPath, err = filepath.Abs(sourceFile) - if err != nil { - panic(err) - } +// loadProviderSource reads SOURCE_FILE into the package-level provider when +// present and returns its absolute path (empty when unset). +func loadProviderSource(sourceFile string, ok bool) string { + if !ok { + return "" + } - providerBytes, err := os.ReadFile(absPath) - if err != nil { - panic(err) - } + absPath, err := filepath.Abs(sourceFile) + if err != nil { + panic(err) + } - provider = string(providerBytes) + providerBytes, err := os.ReadFile(absPath) + if err != nil { + panic(err) } - replaced := strings.ReplaceAll(provider, "##VERSION##", os.Args[1]) + provider = string(providerBytes) + return absPath +} + +func applyChecksums(content string, checksumMap map[string]string, partial bool) string { for k, v := range checksumMap { checksum, err := File(k) if err != nil { @@ -66,18 +88,9 @@ func main() { panic(fmt.Errorf("generate checksum for %s: %w", k, err)) } - replaced = strings.ReplaceAll(replaced, v, checksum) - } - - if ok { - // #nosec G306,G703 -- TODO Consider using a more secure permission setting and ownership if needed. - err := os.WriteFile(absPath, []byte(replaced), 0o644) - if err != nil { - panic(err) - } - } else { - fmt.Println(replaced) + content = strings.ReplaceAll(content, v, checksum) } + return content } // File hashes a given file to a sha256 string.