From 9e40c85480e94f6a5fcf912162d67493a860ad07 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Sun, 26 Jul 2026 11:06:08 -0500 Subject: [PATCH] refactor(pkg): reduce cyclomatic complexity below cyclop threshold Decompose functions in pkg/ that exceeded the golangci-lint cyclop max-complexity of 8 into well-named helpers (guard clauses, extracted predicates, param/result structs, switch/lookup maps), preserving behavior. Extracted helpers bundle args into structs to respect the repo's revive argument-limit/function-result-limit rules, and keep unexported methods ordered after exported ones (funcorder). Also fixes two pre-existing latent bugs surfaced during review: - types: LifecycleHook named array commands used an unchecked type assertion that panicked on non-string elements; now returns ErrUnsupportedType via the existing stringArray helper. - ssh/config: transformHostSection wrapped the (nil) file-open error instead of the scanner error on parse failure. Sets ReadHeaderTimeout on the credentials http.Server (gosec G112). --- pkg/agent/agent.go | 85 ++-- pkg/agent/binary.go | 15 +- pkg/agent/delivery/factory.go | 38 +- pkg/agent/tunnelserver/tunnelserver.go | 182 ++++--- .../daemonclient/client.go | 162 ++++--- .../daemonclient/delete.go | 100 ++-- .../clientimplementation/daemonclient/form.go | 357 +++++++++----- .../clientimplementation/daemonclient/up.go | 210 +++++--- pkg/command/background.go | 47 +- pkg/config/config.go | 95 ++-- pkg/copy/copy.go | 68 +-- pkg/credentials/server.go | 71 +-- pkg/daemon/agent/daemon.go | 40 +- pkg/daemon/platform/workspace_watcher.go | 204 ++++---- pkg/devcontainer/build/options.go | 24 +- pkg/devcontainer/buildkit/buildkit.go | 107 +++-- pkg/devcontainer/buildkit/cache.go | 58 ++- pkg/devcontainer/buildkit/remote.go | 59 ++- pkg/devcontainer/config/config.go | 88 ++-- pkg/devcontainer/config/merge.go | 37 +- pkg/devcontainer/config/parse_test.go | 21 +- pkg/devcontainer/config/result.go | 13 +- pkg/devcontainer/config/substitute.go | 77 +-- pkg/devcontainer/feature/extend.go | 30 +- pkg/devcontainer/feature/features.go | 57 ++- pkg/devcontainer/prebuild.go | 117 +++-- pkg/devcontainer/setup/setup.go | 101 ++-- pkg/devcontainer/single.go | 45 +- pkg/dockercredentials/dockercredentials.go | 128 ++--- pkg/dockerfile/parse.go | 147 +++--- pkg/driver/kubernetes/driver.go | 99 ++-- pkg/driver/kubernetes/init_container.go | 125 +++-- pkg/driver/kubernetes/pullsecrets.go | 54 ++- pkg/driver/kubernetes/pvc.go | 49 +- pkg/driver/kubernetes/run.go | 449 +++++++++++------- pkg/driver/kubernetes/serviceaccount.go | 126 +++-- pkg/driver/kubernetes/wait.go | 427 ++++++++++------- pkg/extract/compress.go | 8 +- pkg/extract/extract.go | 34 +- pkg/gitsshsigning/helper.go | 138 +++--- pkg/ide/fleet/fleet.go | 120 +++-- pkg/ide/ideparse/parse.go | 149 +++--- pkg/ide/jetbrains/generic.go | 9 +- pkg/ide/rstudio/rstudio.go | 51 +- pkg/ide/vscode/vscode.go | 47 +- pkg/inject/inject.go | 184 +++++-- pkg/netstat/netstat_util.go | 127 +++-- pkg/netstat/watcher.go | 101 ++-- pkg/options/resolve.go | 92 ++-- pkg/options/resolve_test.go | 100 ++-- pkg/options/resolver/parse.go | 122 ++--- pkg/options/resolver/resolve.go | 435 ++++++++++------- pkg/options/resolver/resolver.go | 49 +- pkg/options/resolver/util.go | 164 ++++--- pkg/platform/client/client.go | 71 +-- pkg/platform/deploy.go | 268 +++++++---- pkg/platform/form/form.go | 351 +++++++++----- pkg/platform/instance.go | 147 +++--- pkg/platform/kubeconfig.go | 227 +++++---- pkg/platform/owner.go | 32 +- pkg/platform/parameters/parameters.go | 324 +++++++------ pkg/platform/remotecommand/client.go | 36 +- pkg/provider/env.go | 74 +-- pkg/provider/parse.go | 241 ++++++---- pkg/shell/shell.go | 63 ++- pkg/ssh/config.go | 28 +- pkg/ssh/server/ssh.go | 166 ++++--- pkg/telemetry/collect.go | 57 ++- pkg/ts/workspace_server.go | 30 +- pkg/tunnel/browser.go | 72 +-- pkg/tunnel/forwarder.go | 78 +-- pkg/types/types.go | 170 ++++--- pkg/util/homedir.go | 145 +++--- pkg/workspace/exec.go | 47 +- pkg/workspace/list.go | 347 ++++++++------ pkg/workspace/workspace.go | 150 ++++-- 76 files changed, 5659 insertions(+), 3507 deletions(-) diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go index c6f23d275..da247b570 100644 --- a/pkg/agent/agent.go +++ b/pkg/agent/agent.go @@ -107,23 +107,7 @@ func ReadAgentWorkspaceInfo( return false, nil, err } - if errors.Is(err, ErrFindAgentHomeDir) { - log.Debugf( - "agent home folder not found: agentFolder=%s, context=%s, workspaceId=%s", - agentFolder, - context, - id, - ) - } - - if errors.Is(err, os.ErrPermission) { - log.Debugf( - "permission denied reading workspace info: agentFolder=%s, context=%s, workspaceId=%s", - agentFolder, - context, - id, - ) - } + logWorkspaceInfoReadError(err, agentFolder, context, id) // check if we need to become root log.Debug("checking if root privileges are required") @@ -147,6 +131,26 @@ func ReadAgentWorkspaceInfo( return false, workspaceInfo, nil } +func logWorkspaceInfoReadError(err error, agentFolder, context, id string) { + if errors.Is(err, ErrFindAgentHomeDir) { + log.Debugf( + "agent home folder not found: agentFolder=%s, context=%s, workspaceId=%s", + agentFolder, + context, + id, + ) + } + + if errors.Is(err, os.ErrPermission) { + log.Debugf( + "permission denied reading workspace info: agentFolder=%s, context=%s, workspaceId=%s", + agentFolder, + context, + id, + ) + } +} + func WorkspaceInfo( workspaceInfoEncoded string, ) (bool, *provider2.AgentWorkspaceInfo, error) { @@ -203,16 +207,28 @@ func decodeWorkspaceInfoAndWrite( resolveContentFolder(workspaceInfo, workspaceDir) - if writeInfo { - if err := writeWorkspaceInfo(workspaceConfig, workspaceInfo); err != nil { - return false, nil, fmt.Errorf("write workspace info: %w", err) - } + if err := persistWorkspaceInfo(writeInfo, workspaceConfig, workspaceInfo); err != nil { + return false, nil, err } workspaceInfo.Origin = workspaceDir return false, workspaceInfo, nil } +func persistWorkspaceInfo( + writeInfo bool, + workspaceConfig string, + workspaceInfo *provider2.AgentWorkspaceInfo, +) error { + if !writeInfo { + return nil + } + if err := writeWorkspaceInfo(workspaceConfig, workspaceInfo); err != nil { + return fmt.Errorf("write workspace info: %w", err) + } + return nil +} + // handleStaleWorkspace deletes a workspace whose persisted UID no longer // matches the incoming one, then recreates the workspace dir. Returns the // (possibly new) workspaceDir. @@ -325,10 +341,14 @@ func writeWorkspaceInfo(file string, workspaceInfo *provider2.AgentWorkspaceInfo return nil } +func rootNotRequired(workspaceInfo *provider2.AgentWorkspaceInfo) bool { + return runtime.GOOS != "linux" || os.Getuid() == 0 || + (workspaceInfo != nil && workspaceInfo.Agent.Local == config.BoolTrue) +} + func rerunAsRoot(workspaceInfo *provider2.AgentWorkspaceInfo) (bool, error) { // check if root is required - if runtime.GOOS != "linux" || os.Getuid() == 0 || - (workspaceInfo != nil && workspaceInfo.Agent.Local == config.BoolTrue) { + if rootNotRequired(workspaceInfo) { return false, nil } @@ -426,6 +446,17 @@ func Tunnel(ctx context.Context, opts TunnelOptions) error { return opts.Exec(ctx, user, command, opts.Stdin, opts.Stdout, opts.Stderr) } +func applyDockerEnv(cmd *exec.Cmd, envs map[string]string) { + if len(envs) == 0 { + return + } + newEnvs := os.Environ() + for k, v := range envs { + newEnvs = append(newEnvs, k+"="+v) + } + cmd.Env = newEnvs +} + func dockerReachable(dockerOverride string, envs map[string]string) (bool, error) { docker := "docker" if dockerOverride != "" { @@ -442,13 +473,7 @@ func dockerReachable(dockerOverride string, envs map[string]string) (bool, error } cmd := exec.Command(docker, "ps") - if len(envs) > 0 { - newEnvs := os.Environ() - for k, v := range envs { - newEnvs = append(newEnvs, k+"="+v) - } - cmd.Env = newEnvs - } + applyDockerEnv(cmd, envs) _, err := cmd.CombinedOutput() if err != nil { diff --git a/pkg/agent/binary.go b/pkg/agent/binary.go index 4274024b4..b1e01ba6b 100644 --- a/pkg/agent/binary.go +++ b/pkg/agent/binary.go @@ -338,12 +338,17 @@ func (s *HTTPDownloadSource) streamAndCache( return } - if err := os.Rename(tmpPath, cachePath); err == nil { - success = true - if s.Version != "" && s.Cache != nil { - s.Cache.WriteVersion(arch, s.Version) - } + success = s.finalizeCache(arch, tmpPath, cachePath) +} + +func (s *HTTPDownloadSource) finalizeCache(arch, tmpPath, cachePath string) bool { + if err := os.Rename(tmpPath, cachePath); err != nil { + return false + } + if s.Version != "" && s.Cache != nil { + s.Cache.WriteVersion(arch, s.Version) } + return true } func (s *HTTPDownloadSource) createTempFile( diff --git a/pkg/agent/delivery/factory.go b/pkg/agent/delivery/factory.go index a6d744bbd..7701c0a42 100644 --- a/pkg/agent/delivery/factory.go +++ b/pkg/agent/delivery/factory.go @@ -30,11 +30,7 @@ func NewAgentDelivery(opts FactoryOptions) AgentDelivery { return legacyShellDelivery(opts, "custom driver") case driverType == provider.KubernetesDriver: - if opts.PodExec == nil { - return legacyShellDelivery(opts, "kubernetes pod exec unavailable") - } - log.Debugf("using kubernetes-native delivery (exec stream)") - return &KubernetesDelivery{Exec: opts.PodExec} + return kubernetesDelivery(opts) case driverType == provider.AppleDriver: // Shell delivery launches the agent in one exec, which keeps the VM @@ -47,22 +43,34 @@ func NewAgentDelivery(opts FactoryOptions) AgentDelivery { return remoteDockerDelivery(opts) case driverType == "" || driverType == provider.DockerDriver: - if isDockerLocal(opts.DockerCommand) { - log.Debugf("using local docker delivery (named volume)") - return &LocalDockerDelivery{ - DockerCommand: opts.DockerCommand, - Environment: opts.DockerEnv, - HelperImage: opts.HelperImage, - } - } - log.Debugf("using remote docker delivery for non-local docker daemon") - return remoteDockerDelivery(opts) + return dockerDelivery(opts) default: return legacyShellDelivery(opts, fmt.Sprintf("driver: %s", driverType)) } } +func kubernetesDelivery(opts FactoryOptions) AgentDelivery { + if opts.PodExec == nil { + return legacyShellDelivery(opts, "kubernetes pod exec unavailable") + } + log.Debugf("using kubernetes-native delivery (exec stream)") + return &KubernetesDelivery{Exec: opts.PodExec} +} + +func dockerDelivery(opts FactoryOptions) AgentDelivery { + if isDockerLocal(opts.DockerCommand) { + log.Debugf("using local docker delivery (named volume)") + return &LocalDockerDelivery{ + DockerCommand: opts.DockerCommand, + Environment: opts.DockerEnv, + HelperImage: opts.HelperImage, + } + } + log.Debugf("using remote docker delivery for non-local docker daemon") + return remoteDockerDelivery(opts) +} + func remoteDockerDelivery(opts FactoryOptions) AgentDelivery { return &RemoteDockerDelivery{ DockerCommand: opts.DockerCommand, diff --git a/pkg/agent/tunnelserver/tunnelserver.go b/pkg/agent/tunnelserver/tunnelserver.go index 599c96d6c..ab0ef9ab1 100644 --- a/pkg/agent/tunnelserver/tunnelserver.go +++ b/pkg/agent/tunnelserver/tunnelserver.go @@ -276,63 +276,13 @@ func (t *tunnelServer) GitCredentials( return nil, fmt.Errorf("decode git credentials request: %w", err) } - // A configured git token is returned only for its own host, so it is never - // offered to other hosts git contacts (submodules, redirects). - if t.gitToken != nil && t.gitToken.Token != "" && - strings.EqualFold(credentials.Host, t.gitToken.Host) { - credentials.Username = t.gitToken.Username - credentials.Password = t.gitToken.Token - // #nosec G117 -- git credential response legitimately carries the token. - out, err := json.Marshal(credentials) - if err != nil { - return nil, err - } - return &tunnel.Message{Message: string(out)}, nil + if msg, ok, err := t.gitTokenCredentials(credentials); ok || err != nil { + return msg, err } - if t.platformOptions != nil && t.platformOptions.Enabled { - gitHttpCredentials := slices.Concat( - t.platformOptions.UserCredentials.GitHttp, - t.platformOptions.ProjectCredentials.GitHttp) - if len(gitHttpCredentials) > 0 { - if len(gitHttpCredentials) == 1 { - credentials.Username = gitHttpCredentials[0].User - credentials.Password = gitHttpCredentials[0].Password - credentials.Path = gitHttpCredentials[0].Path - } else { - for _, credential := range gitHttpCredentials { - if credential.Host == credentials.Host { - credentials.Username = credential.User - credentials.Password = credential.Password - credentials.Path = credential.Path - break - } - } - } - } - } else { - if t.workspace != nil && t.workspace.Source.GitRepository != "" { - path, err := gitcredentials.GetHTTPPath(ctx, gitcredentials.GetHttpPathParameters{ - Host: credentials.Host, - Protocol: credentials.Protocol, - CurrentPath: credentials.Path, - Repository: t.workspace.Source.GitRepository, - }) - if err != nil { - return nil, fmt.Errorf("get http path: %w", err) - } - // Set the credentials `path` field to the path component of the Git repository URL. - // This allows downstream credential helpers to figure out which passwords needs to be fetched - credentials.Path = path - } else { - log.Warn("workspace is not available for git credentials") - } - - response, err := gitcredentials.GetCredentials(ctx, credentials) - if err != nil { - return nil, fmt.Errorf("get git response: %w", err) - } - credentials = response + credentials, err = t.populateGitCredentials(ctx, credentials) + if err != nil { + return nil, err } out, err := json.Marshal(credentials) @@ -343,6 +293,32 @@ func (t *tunnelServer) GitCredentials( return &tunnel.Message{Message: string(out)}, nil } +func applyPlatformGitCredentials( + credentials *gitcredentials.GitCredentials, + platformOptions *devsy.PlatformOptions, +) { + gitHttpCredentials := slices.Concat( + platformOptions.UserCredentials.GitHttp, + platformOptions.ProjectCredentials.GitHttp) + if len(gitHttpCredentials) == 0 { + return + } + if len(gitHttpCredentials) == 1 { + credentials.Username = gitHttpCredentials[0].User + credentials.Password = gitHttpCredentials[0].Password + credentials.Path = gitHttpCredentials[0].Path + return + } + for _, credential := range gitHttpCredentials { + if credential.Host == credentials.Host { + credentials.Username = credential.User + credentials.Password = credential.Password + credentials.Path = credential.Path + break + } + } +} + func (t *tunnelServer) GitSSHSignature( ctx context.Context, message *tunnel.Message, @@ -493,7 +469,7 @@ func (t *tunnelServer) StreamMount( message *tunnel.StreamMountRequest, stream tunnel.Tunnel_StreamMountServer, ) error { - if t.platformOptions != nil && t.platformOptions.Enabled && !t.allowPlatformOptions { + if t.platformStreamBlocked() { return fmt.Errorf( "streaming mounts from local computer to platform workspace is not supported. " + "Specify a Git repository to clone instead", @@ -511,19 +487,7 @@ func (t *tunnelServer) StreamMount( return fmt.Errorf("mount %s is not allowed to download", message.Mount) } - // Get .devsyignore files to exclude - excludes := []string{} - if t.workspace != nil { - f, err := os.Open(filepath.Join(t.workspace.Source.LocalFolder, pkgconfig.IgnoreFileName)) - if err == nil { - excludes, err = ignorefile.ReadAll(f) - if err != nil { - log.Warnf("error reading %s file: error=%v", pkgconfig.IgnoreFileName, err) - } - } - } - - excludes = append(excludes, config.BuildArtifactExcludes()...) + excludes := append(t.workspaceIgnoreExcludes(), config.BuildArtifactExcludes()...) buf := bufio.NewWriterSize(NewStreamWriter(stream), 10*1024) err := extract.WriteTarExclude(buf, mount.Source, false, excludes) @@ -534,3 +498,83 @@ func (t *tunnelServer) StreamMount( // make sure buffer is flushed return buf.Flush() } + +func (t *tunnelServer) gitTokenCredentials( + credentials *gitcredentials.GitCredentials, +) (*tunnel.Message, bool, error) { + // A configured git token is returned only for its own host, so it is never + // offered to other hosts git contacts (submodules, redirects). + if t.gitToken == nil || t.gitToken.Token == "" || + !strings.EqualFold(credentials.Host, t.gitToken.Host) { + return nil, false, nil + } + + credentials.Username = t.gitToken.Username + credentials.Password = t.gitToken.Token + // #nosec G117 -- git credential response legitimately carries the token. + out, err := json.Marshal(credentials) + if err != nil { + return nil, false, err + } + return &tunnel.Message{Message: string(out)}, true, nil +} + +func (t *tunnelServer) populateGitCredentials( + ctx context.Context, + credentials *gitcredentials.GitCredentials, +) (*gitcredentials.GitCredentials, error) { + if t.platformOptions != nil && t.platformOptions.Enabled { + applyPlatformGitCredentials(credentials, t.platformOptions) + return credentials, nil + } + return t.resolveHostGitCredentials(ctx, credentials) +} + +func (t *tunnelServer) resolveHostGitCredentials( + ctx context.Context, + credentials *gitcredentials.GitCredentials, +) (*gitcredentials.GitCredentials, error) { + if t.workspace != nil && t.workspace.Source.GitRepository != "" { + path, err := gitcredentials.GetHTTPPath(ctx, gitcredentials.GetHttpPathParameters{ + Host: credentials.Host, + Protocol: credentials.Protocol, + CurrentPath: credentials.Path, + Repository: t.workspace.Source.GitRepository, + }) + if err != nil { + return nil, fmt.Errorf("get http path: %w", err) + } + // Set the credentials `path` field to the path component of the Git repository URL. + // This allows downstream credential helpers to figure out which passwords needs to be fetched + credentials.Path = path + } else { + log.Warn("workspace is not available for git credentials") + } + + response, err := gitcredentials.GetCredentials(ctx, credentials) + if err != nil { + return nil, fmt.Errorf("get git response: %w", err) + } + return response, nil +} + +func (t *tunnelServer) platformStreamBlocked() bool { + return t.platformOptions != nil && t.platformOptions.Enabled && !t.allowPlatformOptions +} + +func (t *tunnelServer) workspaceIgnoreExcludes() []string { + excludes := []string{} + if t.workspace == nil { + return excludes + } + + f, err := os.Open(filepath.Join(t.workspace.Source.LocalFolder, pkgconfig.IgnoreFileName)) + if err == nil { + defer func() { _ = f.Close() }() + excludes, err = ignorefile.ReadAll(f) + if err != nil { + log.Warnf("error reading %s file: error=%v", pkgconfig.IgnoreFileName, err) + } + } + return excludes +} diff --git a/pkg/client/clientimplementation/daemonclient/client.go b/pkg/client/clientimplementation/daemonclient/client.go index a64d412c1..c82733e1b 100644 --- a/pkg/client/clientimplementation/daemonclient/client.go +++ b/pkg/client/clientimplementation/daemonclient/client.go @@ -12,6 +12,7 @@ import ( "sync" "time" + managementv1 "github.com/devsy-org/api/pkg/apis/management/v1" storagev1 "github.com/devsy-org/api/pkg/apis/storage/v1" clientpkg "github.com/devsy-org/devsy/pkg/client" "github.com/devsy-org/devsy/pkg/config" @@ -132,55 +133,30 @@ func (c *client) CheckWorkspaceReachable(ctx context.Context) error { return fmt.Errorf("resolve workspace hostname: %w", err) } err = ts.WaitHostReachable(ctx, c.tsClient, wAddr, 5) - if err != nil { - instance, getWorkspaceErr := c.localClient.GetWorkspace(ctx, c.workspace.UID) - // if we can't reach the daemon try to start the desktop app - if daemon.IsDaemonNotAvailableError(getWorkspaceErr) { - deeplink := fmt.Sprintf( - "devsy://open?workspace=%s&provider=%s&source=%s&ide=%s", - c.workspace.ID, - c.config.Name, - c.workspace.Source.String(), - c.workspace.IDE.Name, - ) - openErr := devsyopen.Run(deeplink) - if openErr != nil { - return getWorkspaceErr // inform user about daemon state - } - // give desktop app a chance to start - time.Sleep(2 * time.Second) - - // let's try again - err = ts.WaitHostReachable(ctx, c.tsClient, wAddr, 20) - if err != nil { - instance, getWorkspaceErr = c.localClient.GetWorkspace(ctx, c.workspace.UID) - } else { - return nil - } - } + if err == nil { + log.Debugf("Host %s is reachable. Proceeding with SSH session", wAddr.Host()) + return nil + } - switch { - case getWorkspaceErr != nil: - return fmt.Errorf("couldn't get workspace: %w", getWorkspaceErr) - case instance.Status.Phase != storagev1.InstanceReady: - return fmt.Errorf( - "workspace is %q, run `devsy workspace up %s` to start it again", - instance.Status.Phase, - c.workspace.ID, - ) - case instance.Status.LastWorkspaceStatus != storagev1.WorkspaceStatusRunning: - return fmt.Errorf( - "workspace is %q, run `devsy workspace up %s` to start it again", - instance.Status.LastWorkspaceStatus, - c.workspace.ID, - ) + instance, getWorkspaceErr := c.localClient.GetWorkspace(ctx, c.workspace.UID) + // if we can't reach the daemon try to start the desktop app + if daemon.IsDaemonNotAvailableError(getWorkspaceErr) { + openErr := c.openDesktopApp() + if openErr != nil { + return getWorkspaceErr // inform user about daemon state } + // give desktop app a chance to start + time.Sleep(2 * time.Second) - return fmt.Errorf("reach host: %w", err) + // let's try again + err = ts.WaitHostReachable(ctx, c.tsClient, wAddr, 20) + if err == nil { + return nil + } + instance, getWorkspaceErr = c.localClient.GetWorkspace(ctx, c.workspace.UID) } - log.Debugf("Host %s is reachable. Proceeding with SSH session", wAddr.Host()) - return nil + return c.workspaceUnreachableError(instance, getWorkspaceErr, err) } func (c *client) SSHClients( @@ -270,32 +246,9 @@ func (c *client) Ping(ctx context.Context, writer io.Writer) error { } for range 10 { - timeoutCtx, cancel := context.WithTimeout(ctx, 5*time.Second) - result, err := c.tsClient.Ping(timeoutCtx, *ip, tailcfg.PingDisco) - cancel() - if err != nil { + if err := c.pingOnce(ctx, *ip, writer); err != nil { return err } - if result.Err != "" { - return errors.New(result.Err) - } - latency := time.Duration(result.LatencySeconds * float64(time.Second)). - Round(time.Millisecond) - via := result.Endpoint - if result.DERPRegionID != 0 { - via = fmt.Sprintf("DERP(%s)", result.DERPRegionCode) - } - _, err = fmt.Fprintf( - writer, - "pong from %s (%s) via %v in %v\n", - result.NodeName, - result.NodeIP, - via, - latency, - ) - if err != nil { - return fmt.Errorf("failed to write ping result: %w", err) - } time.Sleep(time.Second) } @@ -303,6 +256,38 @@ func (c *client) Ping(ctx context.Context, writer io.Writer) error { return nil } +func (c *client) pingOnce(ctx context.Context, ip netip.Addr, writer io.Writer) error { + timeoutCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + result, err := c.tsClient.Ping(timeoutCtx, ip, tailcfg.PingDisco) + if err != nil { + return err + } + if result.Err != "" { + return errors.New(result.Err) + } + latency := time.Duration(result.LatencySeconds * float64(time.Second)). + Round(time.Millisecond) + via := result.Endpoint + if result.DERPRegionID != 0 { + via = fmt.Sprintf("DERP(%s)", result.DERPRegionCode) + } + _, err = fmt.Fprintf( + writer, + "pong from %s (%s) via %v in %v\n", + result.NodeName, + result.NodeIP, + via, + latency, + ) + if err != nil { + return fmt.Errorf("failed to write ping result: %w", err) + } + + return nil +} + func (c *client) initPlatformClient(ctx context.Context) (platformclient.Client, error) { configPath, err := platform.DevsyConfigPath(c.Context(), c.Provider()) if err != nil { @@ -326,3 +311,44 @@ func (c *client) getWorkspaceAddress() (ts.Addr, error) { sshServer.DefaultUserPort, ), nil } + +func (c *client) openDesktopApp() error { + deeplink := fmt.Sprintf( + "devsy://open?workspace=%s&provider=%s&source=%s&ide=%s", + c.workspace.ID, + c.config.Name, + c.workspace.Source.String(), + c.workspace.IDE.Name, + ) + + return devsyopen.Run(deeplink) +} + +func (c *client) workspaceUnreachableError( + instance *managementv1.DevsyWorkspaceInstance, + getWorkspaceErr, reachErr error, +) error { + switch { + case getWorkspaceErr != nil: + return fmt.Errorf("couldn't get workspace: %w", getWorkspaceErr) + case instance == nil: + return fmt.Errorf( + "workspace not found, run `devsy workspace up %s` to start it again", + c.workspace.ID, + ) + case instance.Status.Phase != storagev1.InstanceReady: + return fmt.Errorf( + "workspace is %q, run `devsy workspace up %s` to start it again", + instance.Status.Phase, + c.workspace.ID, + ) + case instance.Status.LastWorkspaceStatus != storagev1.WorkspaceStatusRunning: + return fmt.Errorf( + "workspace is %q, run `devsy workspace up %s` to start it again", + instance.Status.LastWorkspaceStatus, + c.workspace.ID, + ) + } + + return fmt.Errorf("reach host: %w", reachErr) +} diff --git a/pkg/client/clientimplementation/daemonclient/delete.go b/pkg/client/clientimplementation/daemonclient/delete.go index 7206b26d6..3a0981e5f 100644 --- a/pkg/client/clientimplementation/daemonclient/delete.go +++ b/pkg/client/clientimplementation/daemonclient/delete.go @@ -5,10 +5,12 @@ import ( "fmt" "time" + managementv1 "github.com/devsy-org/api/pkg/apis/management/v1" clientpkg "github.com/devsy-org/devsy/pkg/client" "github.com/devsy-org/devsy/pkg/client/clientimplementation" "github.com/devsy-org/devsy/pkg/log" "github.com/devsy-org/devsy/pkg/platform" + "github.com/devsy-org/devsy/pkg/platform/kube" kerrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/wait" @@ -30,35 +32,18 @@ func (c *client) Delete(ctx context.Context, opt clientpkg.DeleteOptions) error ) if err != nil { return err - } else if workspace == nil { + } + if workspace == nil { // delete the workspace folder - err = clientimplementation.DeleteWorkspaceFolder( - clientimplementation.DeleteWorkspaceFolderParams{ - Context: c.workspace.Context, - WorkspaceID: c.workspace.ID, - SSHConfigPath: c.workspace.SSHConfigPath, - SSHConfigIncludePath: c.workspace.SSHConfigIncludePath, - }, - ) - if err != nil { - return err - } - - return nil + return c.deleteWorkspaceFolder() } managementClient, err := baseClient.Management() if err != nil { return err } - var gracePeriod *time.Duration - if opt.GracePeriod != "" { - duration, err := time.ParseDuration(opt.GracePeriod) - if err == nil { - gracePeriod = &duration - } - } + gracePeriod := parseGracePeriod(opt.GracePeriod) // kill the command after the grace period if gracePeriod != nil { var cancel context.CancelFunc @@ -66,23 +51,35 @@ func (c *client) Delete(ctx context.Context, opt clientpkg.DeleteOptions) error defer cancel() } - // delete the workspace - err = managementClient.Loft(). - ManagementV1(). - DevsyWorkspaceInstances(workspace.Namespace). - Delete(ctx, workspace.Name, metav1.DeleteOptions{}) - if err != nil { - if !opt.Force { - return fmt.Errorf("delete workspace: %w", err) - } - - if !kerrors.IsNotFound(err) { - log.Errorf("Error deleting workspace: %v", err) - } + if err := c.deleteInstance(ctx, managementClient, workspace, opt.Force); err != nil { + return err } // delete the workspace folder - err = clientimplementation.DeleteWorkspaceFolder( + if err := c.deleteWorkspaceFolder(); err != nil { + return err + } + + // wait until the workspace is deleted + log.Debugf("Waiting for workspace to get deleted") + return c.waitForWorkspaceDeleted(ctx, managementClient, workspace, gracePeriod) +} + +func parseGracePeriod(raw string) *time.Duration { + if raw == "" { + return nil + } + duration, err := time.ParseDuration(raw) + if err != nil || duration <= 0 { + log.Warnf("Ignoring invalid grace period %q: %v", raw, err) + return nil + } + + return &duration +} + +func (c *client) deleteWorkspaceFolder() error { + return clientimplementation.DeleteWorkspaceFolder( clientimplementation.DeleteWorkspaceFolderParams{ Context: c.workspace.Context, WorkspaceID: c.workspace.ID, @@ -90,19 +87,44 @@ func (c *client) Delete(ctx context.Context, opt clientpkg.DeleteOptions) error SSHConfigIncludePath: c.workspace.SSHConfigIncludePath, }, ) +} + +func (c *client) deleteInstance( + ctx context.Context, + managementClient kube.Interface, + workspace *managementv1.DevsyWorkspaceInstance, + force bool, +) error { + err := managementClient.Loft(). + ManagementV1(). + DevsyWorkspaceInstances(workspace.Namespace). + Delete(ctx, workspace.Name, metav1.DeleteOptions{}) if err != nil { - return err + if !force { + return fmt.Errorf("delete workspace: %w", err) + } + + if !kerrors.IsNotFound(err) { + log.Errorf("Error deleting workspace: %v", err) + } } + return nil +} + +func (c *client) waitForWorkspaceDeleted( + ctx context.Context, + managementClient kube.Interface, + workspace *managementv1.DevsyWorkspaceInstance, + gracePeriod *time.Duration, +) error { // calculate wait timeout waitTimeout := time.Minute if gracePeriod != nil { waitTimeout = *gracePeriod } - // wait until the workspace is deleted - log.Debugf("Waiting for workspace to get deleted") - err = wait.PollUntilContextTimeout( + err := wait.PollUntilContextTimeout( ctx, time.Second, waitTimeout, diff --git a/pkg/client/clientimplementation/daemonclient/form.go b/pkg/client/clientimplementation/daemonclient/form.go index 4edd73915..57eb40cdc 100644 --- a/pkg/client/clientimplementation/daemonclient/form.go +++ b/pkg/client/clientimplementation/daemonclient/form.go @@ -140,20 +140,10 @@ func updateInstanceInteractive( if err != nil { return nil, err } - var selectedTemplate *managementv1.DevsyWorkspaceTemplate - templateOptions := []TemplateOption{} - for _, template := range projectTemplates.DevsyWorkspaceTemplates { - t := &template - templateOptions = append(templateOptions, huh.Option[*managementv1.DevsyWorkspaceTemplate]{ - Key: platform.DisplayName(template.GetName(), template.Spec.DisplayName), - Value: t, - }) - - if instance.Spec.TemplateRef != nil && - instance.Spec.TemplateRef.Name == template.GetName() { - selectedTemplate = t - } - } + templateOptions, selectedTemplate := buildTemplateOptions( + projectTemplates.DevsyWorkspaceTemplates, + instance, + ) if selectedTemplate == nil { return nil, fmt.Errorf("template not found: %#v", instance.Spec.TemplateRef) } @@ -182,64 +172,158 @@ func updateInstanceInteractive( return nil, err } + renderedParameters, err := resolveInstanceParameters( + formCtx, + instance, + selectedTemplate, + selectedTemplateVersion, + ) + if err != nil { + return nil, err + } + + return applyInstanceChanges( + instance, + selectedTemplate, + selectedTemplateVersion, + renderedParameters, + ), nil +} + +func buildTemplateOptions( + templates []managementv1.DevsyWorkspaceTemplate, + instance *managementv1.DevsyWorkspaceInstance, +) ([]TemplateOption, *managementv1.DevsyWorkspaceTemplate) { + templateOptions := []TemplateOption{} + var selectedTemplate *managementv1.DevsyWorkspaceTemplate + for _, template := range templates { + t := &template + templateOptions = append(templateOptions, huh.Option[*managementv1.DevsyWorkspaceTemplate]{ + Key: platform.DisplayName(template.GetName(), template.Spec.DisplayName), + Value: t, + }) + + if instance.Spec.TemplateRef != nil && + instance.Spec.TemplateRef.Name == template.GetName() { + selectedTemplate = t + } + } + + return templateOptions, selectedTemplate +} + +func resolveInstanceParameters( + formCtx context.Context, + instance *managementv1.DevsyWorkspaceInstance, + selectedTemplate *managementv1.DevsyWorkspaceTemplate, + selectedTemplateVersion string, +) (string, error) { parameters := selectedTemplate.Spec.Parameters if len(selectedTemplate.GetVersions()) > 0 { + var err error parameters, err = list.GetTemplateParameters(selectedTemplate, selectedTemplateVersion) if err != nil { - return nil, err + return "", err } } + if len(parameters) == 0 { + return "", nil + } - renderedParameters := "" - if len(parameters) > 0 { - tRef := instance.Spec.TemplateRef - var existingParameters map[string]any - if tRef != nil && tRef.Name == selectedTemplate.GetName() && - tRef.Version == selectedTemplateVersion { - existingParameters = map[string]any{} - err = yaml.Unmarshal([]byte(instance.Spec.Parameters), &existingParameters) - if err != nil { - return nil, err - } - } + existingParameters, err := existingParametersFor( + instance, + selectedTemplate, + selectedTemplateVersion, + ) + if err != nil { + return "", err + } - fieldParameters := []*FieldParameter{} - // reuse existing parameters as starting point - for _, p := range parameters { - var value any = p.DefaultValue - if existingParameters != nil { - value = getDeepValue(existingParameters, p.Variable) - } - fieldParameter := FieldParameter{AppParameter: p} - - if p.Type == "boolean" && value != nil { - v, err := strconv.ParseBool(value.(string)) - if err == nil { - fieldParameter.BoolValue = v - } - } else { - if value != nil { - fieldParameter.StringValue = value.(string) - } else { - fieldParameter.StringValue = p.DefaultValue - } - } - fieldParameters = append(fieldParameters, &fieldParameter) - } + fieldParameters := fieldParametersWithValues(parameters, existingParameters) - err = huh.NewForm( - huh.NewGroup(parameterFields(fieldParameters)...), - ).RunWithContext(formCtx) - if err != nil { - return nil, err + err = huh.NewForm( + huh.NewGroup(parameterFields(fieldParameters)...), + ).RunWithContext(formCtx) + if err != nil { + return "", err + } + + return renderParameters(fieldParameters) +} + +func existingParametersFor( + instance *managementv1.DevsyWorkspaceInstance, + selectedTemplate *managementv1.DevsyWorkspaceTemplate, + selectedTemplateVersion string, +) (map[string]any, error) { + tRef := instance.Spec.TemplateRef + if tRef == nil || tRef.Name != selectedTemplate.GetName() || + tRef.Version != selectedTemplateVersion { + return nil, nil + } + + existingParameters := map[string]any{} + if err := yaml.Unmarshal([]byte(instance.Spec.Parameters), &existingParameters); err != nil { + return nil, err + } + + return existingParameters, nil +} + +func fieldParametersWithValues( + parameters []storagev1.AppParameter, + existingParameters map[string]any, +) []*FieldParameter { + fieldParameters := []*FieldParameter{} + for _, p := range parameters { + var value any = p.DefaultValue + if existingParameters != nil { + value = getDeepValue(existingParameters, p.Variable) } + fieldParameter := FieldParameter{AppParameter: p} + assignParameterValue(&fieldParameter, value) + fieldParameters = append(fieldParameters, &fieldParameter) + } - renderedParameters, err = renderParameters(fieldParameters) - if err != nil { - return nil, err + return fieldParameters +} + +func assignParameterValue(fieldParameter *FieldParameter, value any) { + if fieldParameter.Type == paramTypeBoolean { + assignBoolValue(fieldParameter, value) + return + } + if value == nil { + fieldParameter.StringValue = fieldParameter.DefaultValue + return + } + if s, ok := value.(string); ok { + fieldParameter.StringValue = s + } else { + fieldParameter.StringValue = fmt.Sprintf("%v", value) + } +} + +func assignBoolValue(fieldParameter *FieldParameter, value any) { + switch v := value.(type) { + case bool: + fieldParameter.BoolValue = v + case string: + if b, err := strconv.ParseBool(v); err == nil { + fieldParameter.BoolValue = b + } + default: + if b, err := strconv.ParseBool(fieldParameter.DefaultValue); err == nil { + fieldParameter.BoolValue = b } } +} +func applyInstanceChanges( + instance *managementv1.DevsyWorkspaceInstance, + selectedTemplate *managementv1.DevsyWorkspaceTemplate, + selectedTemplateVersion, renderedParameters string, +) *managementv1.DevsyWorkspaceInstance { newInstance := instance.DeepCopy() // template if instance.Spec.TemplateRef != nil && @@ -256,7 +340,7 @@ func updateInstanceInteractive( newInstance.Spec.Parameters = renderedParameters } - return newInstance, nil + return newInstance } type ( @@ -265,6 +349,8 @@ type ( CancelFunc = func() ) +const paramTypeBoolean = "boolean" + var latestTemplateVersion = huh.Option[string]{ Key: "latest", Value: "", @@ -385,7 +471,7 @@ func prepareParameters(parameters []storagev1.AppParameter) []*FieldParameter { retParams := []*FieldParameter{} for _, p := range parameters { fieldParameter := FieldParameter{AppParameter: p} - if p.Type == "boolean" { + if p.Type == paramTypeBoolean { v, err := strconv.ParseBool(p.DefaultValue) if err == nil { fieldParameter.BoolValue = v @@ -403,71 +489,76 @@ func prepareParameters(parameters []storagev1.AppParameter) []*FieldParameter { func parameterFields(fieldParameters []*FieldParameter) []huh.Field { fields := []huh.Field{} for _, param := range fieldParameters { - title := param.Label - if title == "" { - title = param.Variable - } - paramType := param.Type - if paramType == "" { - paramType = "string" - } + fields = append(fields, parameterField(param)) + } + + return fields +} - var field huh.Field - switch paramType { - case "multiline": - field = huh.NewText(). - Title(title). - Description(param.Description). - Value(¶m.StringValue) - case "password": - fallthrough - case "number": - fallthrough - case "string": - // display a select field if param has options - if len(param.Options) > 0 { - opts := []huh.Option[string]{} - for _, o := range param.Options { - huhOption := huh.Option[string]{ - Key: o, - Value: o, - } - if o == param.DefaultValue { - huhOption = huhOption.Selected(true) - } - opts = append(opts, huhOption) - } - field = huh.NewSelect[string](). - Title(title). - Options(opts...). - Value(¶m.StringValue) - } else { - input := huh.NewInput().Title(title). - Description(param.Description). - Value(¶m.StringValue) - - if param.Type == "password" { - input.EchoMode(huh.EchoModePassword) - } - if param.Type == "number" { - input.Validate(func(s string) error { - _, err := strconv.ParseFloat(s, 64) - return err - }) - } - field = input +func parameterField(param *FieldParameter) huh.Field { + title := param.Label + if title == "" { + title = param.Variable + } + paramType := param.Type + if paramType == "" { + paramType = "string" + } + + switch paramType { + case "multiline": + return huh.NewText(). + Title(title). + Description(param.Description). + Value(¶m.StringValue) + case "password", "number", "string": + return stringParameterField(param, title) + case paramTypeBoolean: + return huh.NewConfirm(). + Title(title). + Description(param.Description). + Value(¶m.BoolValue) + } + + return stringParameterField(param, title) +} + +func stringParameterField(param *FieldParameter, title string) huh.Field { + // display a select field if param has options + if len(param.Options) > 0 { + opts := []huh.Option[string]{} + for _, o := range param.Options { + huhOption := huh.Option[string]{ + Key: o, + Value: o, + } + if o == param.DefaultValue { + huhOption = huhOption.Selected(true) } - case "boolean": - field = huh.NewConfirm(). - Title(title). - Description(param.Description). - Value(¶m.BoolValue) + opts = append(opts, huhOption) } - fields = append(fields, field) + return huh.NewSelect[string](). + Title(title). + Options(opts...). + Value(¶m.StringValue) } - return fields + input := huh.NewInput().Title(title). + Description(param.Description). + Value(¶m.StringValue) + + if param.Type == "password" { + input.EchoMode(huh.EchoModePassword) + } + if param.Type == "number" { + input.Validate(func(s string) error { + _, err := strconv.ParseFloat(s, 64) + return err + }) + } + + return input } func renderParameters(fieldParameters []*FieldParameter) (string, error) { @@ -505,20 +596,24 @@ func getDeepValue(parameters any, path string) any { return getDeepValue(val, strings.Join(pathSegments[1:], ".")) case []any: - index, err := strconv.Atoi(pathSegments[0]) - if err != nil { - return nil - } else if index < 0 || index >= len(t) { - return nil - } + return getDeepValueFromSlice(t, pathSegments) + } - val := t[index] - if len(pathSegments) == 1 { - return val - } + return nil +} - return getDeepValue(val, strings.Join(pathSegments[1:], ".")) +func getDeepValueFromSlice(t []any, pathSegments []string) any { + index, err := strconv.Atoi(pathSegments[0]) + if err != nil { + return nil + } else if index < 0 || index >= len(t) { + return nil } - return nil + val := t[index] + if len(pathSegments) == 1 { + return val + } + + return getDeepValue(val, strings.Join(pathSegments[1:], ".")) } diff --git a/pkg/client/clientimplementation/daemonclient/up.go b/pkg/client/clientimplementation/daemonclient/up.go index 9516ca3fc..0545a1710 100644 --- a/pkg/client/clientimplementation/daemonclient/up.go +++ b/pkg/client/clientimplementation/daemonclient/up.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "os" "time" @@ -18,6 +19,7 @@ import ( "github.com/devsy-org/devsy/pkg/devcontainer/config" "github.com/devsy-org/devsy/pkg/log" "github.com/devsy-org/devsy/pkg/platform" + platformclient "github.com/devsy-org/devsy/pkg/platform/client" "github.com/devsy-org/devsy/pkg/platform/kube" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -36,7 +38,8 @@ func (c *client) Up(ctx context.Context, opt clientpkg.UpOptions) (*config.Resul ) if err != nil { return nil, err - } else if instance == nil { + } + if instance == nil { return nil, fmt.Errorf( "workspace %s not found. Looks like it does not exist anymore and you can delete it", c.workspace.ID, @@ -44,83 +47,160 @@ func (c *client) Up(ctx context.Context, opt clientpkg.UpOptions) (*config.Resul } // check if the workspace is migrated and we need to force recreate or reset - if instance.Annotations["devsy.sh/migrated"] == devsyconfig.BoolTrue && !opt.Recreate && - !opt.Reset { - if os.Getenv(devsyconfig.EnvUI) == devsyconfig.BoolTrue { - return nil, fmt.Errorf( - "workspace %s is migrated and needs to be rebuild or reset. "+ - "Click on rebuild or reset on the workspace to do this", - c.workspace.ID, - ) - } else { - return nil, fmt.Errorf( - "workspace %s is migrated and needs to be recreated or reset. Use the recreate or reset flag to do this", - c.workspace.ID, - ) - } + if err := migratedRebuildError(instance, c.workspace.ID, opt); err != nil { + return nil, err } // Log current workspace information. This is both useful to the user to understand the workspace configuration // and to us when we receive troubleshooting logs printInstanceInfo(instance) - if instance.Spec.TemplateRef != nil && templateUpdateRequired(instance) { - log.Info("Template update required") - oldInstance := instance.DeepCopy() - instance.Spec.TemplateRef.SyncOnce = true + instance, err = syncTemplateIfRequired(ctx, baseClient, instance) + if err != nil { + return nil, err + } - instance, err = platform.UpdateInstance(ctx, baseClient, oldInstance, instance) - if err != nil { - return nil, fmt.Errorf("update instance: %w", err) - } - log.Info("updated template") + managementClient, taskID, err := startUpTask(ctx, baseClient, instance, opt) + if err != nil { + return nil, err + } + + return waitTaskDone(ctx, managementClient, instance, taskID) +} + +func migratedRebuildError( + instance *managementv1.DevsyWorkspaceInstance, + workspaceID string, + opt clientpkg.UpOptions, +) error { + if instance.Annotations["devsy.sh/migrated"] != devsyconfig.BoolTrue || opt.Recreate || + opt.Reset { + return nil } + if os.Getenv(devsyconfig.EnvUI) == devsyconfig.BoolTrue { + return fmt.Errorf( + "workspace %s is migrated and needs to be rebuild or reset. "+ + "Click on rebuild or reset on the workspace to do this", + workspaceID, + ) + } + + return fmt.Errorf( + "workspace %s is migrated and needs to be recreated or reset. Use the recreate or reset flag to do this", + workspaceID, + ) +} + +func syncTemplateIfRequired( + ctx context.Context, + baseClient platformclient.Client, + instance *managementv1.DevsyWorkspaceInstance, +) (*managementv1.DevsyWorkspaceInstance, error) { + if instance.Spec.TemplateRef == nil || !templateUpdateRequired(instance) { + return instance, nil + } + + log.Info("Template update required") + oldInstance := instance.DeepCopy() + instance.Spec.TemplateRef.SyncOnce = true + + updated, err := platform.UpdateInstance(ctx, baseClient, oldInstance, instance) + if err != nil { + return nil, fmt.Errorf("update instance: %w", err) + } + log.Info("updated template") + + return updated, nil +} + +func startUpTask( + ctx context.Context, + baseClient platformclient.Client, + instance *managementv1.DevsyWorkspaceInstance, + opt clientpkg.UpOptions, +) (kube.Interface, string, error) { // encode options rawOptions, _ := json.Marshal(opt) managementClient, err := baseClient.Management() if err != nil { - return nil, fmt.Errorf("error getting management client: %w", err) + return nil, "", fmt.Errorf("error getting management client: %w", err) } // prompt user to attach to active task or start new one log.Debug("Check active up task") activeUpTask, err := findActiveUpTask(ctx, managementClient, instance) if err != nil { - return nil, fmt.Errorf("find active up task: %w", err) + return nil, "", fmt.Errorf("find active up task: %w", err) } // if we have an active up task, cancel it before creating a new one - if activeUpTask != nil { - log.Warnf("Found active up task %s, attempting to cancel it", activeUpTask.ID) - _, err = managementClient.Loft(). - ManagementV1(). - DevsyWorkspaceInstances(instance.Namespace). - Cancel(ctx, instance.Name, &managementv1.DevsyWorkspaceInstanceCancel{ - TaskID: activeUpTask.ID, - }, metav1.CreateOptions{}) - if err != nil { - return nil, fmt.Errorf("cancel task: %w", err) - } + if err := cancelActiveUpTask(ctx, managementClient, instance, activeUpTask); err != nil { + return nil, "", err + } + + taskID, err := createUpTask(ctx, createUpTaskParams{ + managementClient: managementClient, + instance: instance, + opt: opt, + rawOptions: string(rawOptions), + }) + if err != nil { + return nil, "", err + } + + return managementClient, taskID, nil +} + +type createUpTaskParams struct { + managementClient kube.Interface + instance *managementv1.DevsyWorkspaceInstance + opt clientpkg.UpOptions + rawOptions string +} + +func cancelActiveUpTask( + ctx context.Context, + managementClient kube.Interface, + instance *managementv1.DevsyWorkspaceInstance, + activeUpTask *managementv1.DevsyWorkspaceInstanceTask, +) error { + if activeUpTask == nil { + return nil } - // create up task - task, err := managementClient.Loft(). + log.Warnf("Found active up task %s, attempting to cancel it", activeUpTask.ID) + _, err := managementClient.Loft(). ManagementV1(). DevsyWorkspaceInstances(instance.Namespace). - Up(ctx, instance.Name, &managementv1.DevsyWorkspaceInstanceUp{ + Cancel(ctx, instance.Name, &managementv1.DevsyWorkspaceInstanceCancel{ + TaskID: activeUpTask.ID, + }, metav1.CreateOptions{}) + if err != nil { + return fmt.Errorf("cancel task: %w", err) + } + + return nil +} + +func createUpTask(ctx context.Context, params createUpTaskParams) (string, error) { + task, err := params.managementClient.Loft(). + ManagementV1(). + DevsyWorkspaceInstances(params.instance.Namespace). + Up(ctx, params.instance.Name, &managementv1.DevsyWorkspaceInstanceUp{ Spec: managementv1.DevsyWorkspaceInstanceUpSpec{ - Debug: opt.Debug, - Options: string(rawOptions), + Debug: params.opt.Debug, + Options: params.rawOptions, }, }, metav1.CreateOptions{}) if err != nil { - return nil, fmt.Errorf("error creating up: %w", err) - } else if task.Status.TaskID == "" { - return nil, fmt.Errorf("no up task id returned from server") + return "", fmt.Errorf("error creating up: %w", err) + } + if task.Status.TaskID == "" { + return "", fmt.Errorf("no up task id returned from server") } - return waitTaskDone(ctx, managementClient, instance, task.Status.TaskID) + return task.Status.TaskID, nil } func waitTaskDone( @@ -314,21 +394,9 @@ func printLogs( return -1, fmt.Errorf("error parsing JSON from logs reader: %w, line: %s", err, line) } - // write message to stdout or stderr - switch message.Type { - case StdoutData: - if _, err := stdoutStreamer.Write(message.Bytes); err != nil { - log.Debugf("error read stdout: %v", err) - return 1, err - } - case StderrData: - if _, err := stderrStreamer.Write(message.Bytes); err != nil { - log.Debugf("error read stderr: %v", err) - return 1, err - } - case ExitCode: - log.Debugf("exit code: %d", message.ExitCode) - return message.ExitCode, nil + exitCode, done, err := writeMessage(stdoutStreamer, stderrStreamer, message) + if done { + return exitCode, err } } if err := scanner.Err(); err != nil { @@ -341,6 +409,26 @@ func printLogs( return 0, nil } +func writeMessage(stdout, stderr io.Writer, message *Message) (int, bool, error) { + switch message.Type { + case StdoutData: + if _, err := stdout.Write(message.Bytes); err != nil { + log.Debugf("error read stdout: %v", err) + return 1, true, err + } + case StderrData: + if _, err := stderr.Write(message.Bytes); err != nil { + log.Debugf("error read stderr: %v", err) + return 1, true, err + } + case ExitCode: + log.Debugf("exit code: %d", message.ExitCode) + return message.ExitCode, true, nil + } + + return 0, false, nil +} + const ( TaskStatusRunning = "Running" TaskStatusSucceed = "Succeeded" diff --git a/pkg/command/background.go b/pkg/command/background.go index b97d9a109..70f97098d 100644 --- a/pkg/command/background.go +++ b/pkg/command/background.go @@ -21,22 +21,14 @@ type CreateCommand func() (*exec.Cmd, error) // command already has Stdout/Stderr configured. The PID is recorded in // TMPDIR/commandName.pid. These files are not cleaned up on exit. func StartBackgroundOnce(commandName string, createCommand CreateCommand) error { - lockFile, err := config.DefaultPathManager().ProcessLockFile(commandName) - if err != nil { - return fmt.Errorf("process lock file: %w", err) - } - pidFile, err := config.DefaultPathManager().ProcessPIDFile(commandName) + paths, err := resolveProcessPaths(commandName) if err != nil { - return fmt.Errorf("process pid file: %w", err) - } - streamsFile, err := config.DefaultPathManager().ProcessStreamsFile(commandName) - if err != nil { - return fmt.Errorf("process streams file: %w", err) + return err } // Create a file-based lock to prevent multiple invocations of this function // before the process is created. - fileLock := flock.New(lockFile) + fileLock := flock.New(paths.lockFile) locked, err := fileLock.TryLock() if err != nil { return fmt.Errorf("acquire lock: %w", err) @@ -45,11 +37,16 @@ func StartBackgroundOnce(commandName string, createCommand CreateCommand) error } defer func() { if unlockErr := fileLock.Unlock(); unlockErr != nil { - fmt.Fprintf(os.Stderr, "warning: failed to release lock %s: %v\n", lockFile, unlockErr) + fmt.Fprintf( + os.Stderr, + "warning: failed to release lock %s: %v\n", + paths.lockFile, + unlockErr, + ) } }() - running, err := isProcessRunning(pidFile) + running, err := isProcessRunning(paths.pidFile) if err != nil { return err } @@ -62,7 +59,29 @@ func StartBackgroundOnce(commandName string, createCommand CreateCommand) error return err } - return startCommand(cmd, pidFile, streamsFile) + return startCommand(cmd, paths.pidFile, paths.streamsFile) +} + +type processPaths struct { + lockFile string + pidFile string + streamsFile string +} + +func resolveProcessPaths(commandName string) (processPaths, error) { + lockFile, err := config.DefaultPathManager().ProcessLockFile(commandName) + if err != nil { + return processPaths{}, fmt.Errorf("process lock file: %w", err) + } + pidFile, err := config.DefaultPathManager().ProcessPIDFile(commandName) + if err != nil { + return processPaths{}, fmt.Errorf("process pid file: %w", err) + } + streamsFile, err := config.DefaultPathManager().ProcessStreamsFile(commandName) + if err != nil { + return processPaths{}, fmt.Errorf("process streams file: %w", err) + } + return processPaths{lockFile: lockFile, pidFile: pidFile, streamsFile: streamsFile}, nil } // StartBackground starts a background process unconditionally, without any diff --git a/pkg/config/config.go b/pkg/config/config.go index 37613219f..7ede6d0c9 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -119,12 +119,8 @@ func (c *Config) IDEOptions(ide string) map[string]OptionValue { } func (c *Config) ContextOption(option string) string { - if c.Contexts != nil { - if _, ok := c.Contexts[c.DefaultContext]; ok && c.Current().Options != nil { - if _, ok := c.Current().Options[option]; ok && c.Current().Options[option].Value != "" { - return c.Current().Options[option].Value - } - } + if v, ok := c.contextOptionValue(option); ok { + return v } for _, contextOption := range ContextOptions { @@ -145,6 +141,19 @@ func (c *Config) ContextOptionBool(option string) bool { return c.ContextOption(option) == BoolTrue } +func (c *Config) contextOptionValue(option string) (string, bool) { + if c.Contexts == nil { + return "", false + } + if _, ok := c.Contexts[c.DefaultContext]; !ok || c.Current().Options == nil { + return "", false + } + if v, ok := c.Current().Options[option]; ok && v.Value != "" { + return v.Value, true + } + return "", false +} + func (c *ContextConfig) IsSingleMachine(provider string) bool { if c.Providers == nil || c.Providers[provider] == nil { return false @@ -219,23 +228,7 @@ func LoadConfig(contextOverride string, providerOverride string) (*Config, error return nil, fmt.Errorf("read config: %w", err) } - context := contextOverride - if context == "" { - context = DefaultContext - } - - return &Config{ - DefaultContext: context, - Contexts: map[string]*ContextConfig{ - context: { - DefaultProvider: providerOverride, - Providers: map[string]*ProviderConfig{}, - IDEs: map[string]*IDEConfig{}, - Options: map[string]OptionValue{}, - }, - }, - Origin: configOrigin, - }, nil + return newMissingConfig(configOrigin, contextOverride, providerOverride), nil } config := &Config{} @@ -243,6 +236,34 @@ func LoadConfig(contextOverride string, providerOverride string) (*Config, error if err != nil { return nil, err } + + normalizeConfig(config, contextOverride, providerOverride) + config.Origin = configOrigin + + return config, nil +} + +func newMissingConfig(configOrigin, contextOverride, providerOverride string) *Config { + context := contextOverride + if context == "" { + context = DefaultContext + } + + return &Config{ + DefaultContext: context, + Contexts: map[string]*ContextConfig{ + context: { + DefaultProvider: providerOverride, + Providers: map[string]*ProviderConfig{}, + IDEs: map[string]*IDEConfig{}, + Options: map[string]OptionValue{}, + }, + }, + Origin: configOrigin, + } +} + +func normalizeConfig(config *Config, contextOverride, providerOverride string) { if contextOverride != "" { config.OriginalContext = config.DefaultContext config.DefaultContext = contextOverride @@ -255,23 +276,25 @@ func LoadConfig(contextOverride string, providerOverride string) (*Config, error if config.Contexts[config.DefaultContext] == nil { config.Contexts[config.DefaultContext] = &ContextConfig{} } - if config.Contexts[config.DefaultContext].Options == nil { - config.Contexts[config.DefaultContext].Options = map[string]OptionValue{} + + ctx := config.Contexts[config.DefaultContext] + ensureContextMaps(ctx) + if providerOverride != "" { + ctx.OriginalProvider = ctx.DefaultProvider + ctx.DefaultProvider = providerOverride } - if config.Contexts[config.DefaultContext].Providers == nil { - config.Contexts[config.DefaultContext].Providers = map[string]*ProviderConfig{} +} + +func ensureContextMaps(ctx *ContextConfig) { + if ctx.Options == nil { + ctx.Options = map[string]OptionValue{} } - if config.Contexts[config.DefaultContext].IDEs == nil { - config.Contexts[config.DefaultContext].IDEs = map[string]*IDEConfig{} + if ctx.Providers == nil { + ctx.Providers = map[string]*ProviderConfig{} } - if providerOverride != "" { - config.Contexts[config.DefaultContext].OriginalProvider = config.Contexts[config.DefaultContext].DefaultProvider - config.Contexts[config.DefaultContext].DefaultProvider = providerOverride + if ctx.IDEs == nil { + ctx.IDEs = map[string]*IDEConfig{} } - - config.Origin = configOrigin - - return config, nil } func SaveConfig(config *Config) error { diff --git a/pkg/copy/copy.go b/pkg/copy/copy.go index 183115c9e..41b4bd85f 100644 --- a/pkg/copy/copy.go +++ b/pkg/copy/copy.go @@ -77,50 +77,50 @@ func Directory(scrDir, dest string) error { return err } for _, entry := range entries { - sourcePath := filepath.Join(scrDir, entry.Name()) - destPath := filepath.Join(dest, entry.Name()) - - fileInfo, err := os.Stat(sourcePath) - if err != nil { + if err := copyEntry(scrDir, dest, entry); err != nil { return err } + } + return nil +} - switch fileInfo.Mode() & os.ModeType { - case os.ModeDir: - if err := CreateIfNotExists(destPath, 0o755); err != nil { - return err - } - if err := Directory(sourcePath, destPath); err != nil { - return err - } - case os.ModeSymlink: - if err := Symlink(sourcePath, destPath); err != nil { - return err - } - default: - if err := File(sourcePath, destPath, 0o644); err != nil { - return err - } - } +func copyEntry(scrDir, dest string, entry fs.DirEntry) error { + sourcePath := filepath.Join(scrDir, entry.Name()) + destPath := filepath.Join(dest, entry.Name()) - err = Lchown(fileInfo, sourcePath, destPath) - if err != nil { - return err - } + fileInfo, err := entry.Info() + if err != nil { + return err + } - fInfo, err := entry.Info() - if err != nil { + if err := copyByType(fileInfo, sourcePath, destPath); err != nil { + return err + } + + if err := Lchown(fileInfo, sourcePath, destPath); err != nil { + return err + } + + if fileInfo.Mode()&os.ModeSymlink == 0 { + if err := os.Chmod(destPath, fileInfo.Mode()); err != nil { return err } + } + return nil +} - isSymlink := fInfo.Mode()&os.ModeSymlink != 0 - if !isSymlink { - if err := os.Chmod(destPath, fInfo.Mode()); err != nil { - return err - } +func copyByType(fileInfo os.FileInfo, sourcePath, destPath string) error { + switch fileInfo.Mode() & os.ModeType { + case os.ModeDir: + if err := CreateIfNotExists(destPath, 0o755); err != nil { + return err } + return Directory(sourcePath, destPath) + case os.ModeSymlink: + return Symlink(sourcePath, destPath) + default: + return File(sourcePath, destPath, 0o644) } - return nil } func File(srcFile, dstFile string, perm os.FileMode) error { diff --git a/pkg/credentials/server.go b/pkg/credentials/server.go index 8fe1b8018..03a8fd4a4 100644 --- a/pkg/credentials/server.go +++ b/pkg/credentials/server.go @@ -10,6 +10,7 @@ import ( "net/http" "os" "strconv" + "time" "github.com/devsy-org/devsy/pkg/agent/tunnel" "github.com/devsy-org/devsy/pkg/config" @@ -44,42 +45,12 @@ func RunCredentialsServer( port int, client CredentialsClient, ) error { - var handler http.Handler = http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { - log.Debugf("incoming client connection: path=%s", request.URL.Path) - switch request.URL.Path { - case "/git-credentials": - err := handleGitCredentialsRequest(ctx, writer, request, client) - if err != nil { - http.Error(writer, err.Error(), http.StatusInternalServerError) - return - } - case "/docker-credentials": - err := handleDockerCredentialsRequest(ctx, writer, request, client) - if err != nil { - http.Error(writer, err.Error(), http.StatusInternalServerError) - return - } - case "/git-ssh-signature": - err := handleGitSSHSignatureRequest(ctx, writer, request, client) - if err != nil { - http.Error(writer, err.Error(), http.StatusInternalServerError) - return - } - case "/devsy-platform-credentials": - err := handleDevsyPlatformCredentialsRequest(ctx, writer, request, client) - if err != nil { - http.Error(writer, err.Error(), http.StatusInternalServerError) - } - case "/gpg-public-keys": - err := handleGPGPublicKeysRequest(ctx, writer, client) - if err != nil { - http.Error(writer, err.Error(), http.StatusInternalServerError) - } - } - }) - addr := net.JoinHostPort("localhost", strconv.Itoa(port)) - srv := &http.Server{Addr: addr, Handler: handler} + srv := &http.Server{ + Addr: addr, + Handler: newCredentialsHandler(ctx, client), + ReadHeaderTimeout: 10 * time.Second, + } errChan := make(chan error, 1) go func() { @@ -102,6 +73,36 @@ func RunCredentialsServer( } } +type credentialsHandlerFunc func( + context.Context, http.ResponseWriter, *http.Request, CredentialsClient, +) error + +func newCredentialsHandler(ctx context.Context, client CredentialsClient) http.Handler { + routes := map[string]credentialsHandlerFunc{ + "/git-credentials": handleGitCredentialsRequest, + "/docker-credentials": handleDockerCredentialsRequest, + "/git-ssh-signature": handleGitSSHSignatureRequest, + "/devsy-platform-credentials": handleDevsyPlatformCredentialsRequest, + "/gpg-public-keys": func( + ctx context.Context, writer http.ResponseWriter, _ *http.Request, client CredentialsClient, + ) error { + return handleGPGPublicKeysRequest(ctx, writer, client) + }, + } + + return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + log.Debugf("incoming client connection: path=%s", request.URL.Path) + handler, ok := routes[request.URL.Path] + if !ok { + http.NotFound(writer, request) + return + } + if err := handler(ctx, writer, request, client); err != nil { + http.Error(writer, err.Error(), http.StatusInternalServerError) + } + }) +} + func GetPort() (int, error) { strPort := cmp.Or(os.Getenv(config.EnvCredentialsServerPort), DefaultPort) port, err := strconv.Atoi(strPort) diff --git a/pkg/daemon/agent/daemon.go b/pkg/daemon/agent/daemon.go index 95da451d8..bf3ce2b66 100644 --- a/pkg/daemon/agent/daemon.go +++ b/pkg/daemon/agent/daemon.go @@ -303,24 +303,32 @@ func RemoveDaemon() error { } // stop and disable the service, propagating real errors - //nolint:gosec // BinaryName is a compile-time constant - out, err := exec.Command("systemctl", "stop", pkgconfig.BinaryName). - CombinedOutput() - if err != nil { - // "not loaded" means the unit doesn't exist — treat as no-op - if !strings.Contains(string(out), "not loaded") { - return fmt.Errorf("systemctl stop: %s: %w", string(out), err) - } + if err := systemctlIgnoringNotLoaded("stop"); err != nil { + return err } + if err := systemctlIgnoringNotLoaded("disable"); err != nil { + return err + } + + return removeServiceUnit() +} + +// systemctlIgnoringNotLoaded runs a systemctl action against the daemon service, +// treating a "not loaded" result (the unit does not exist) as a no-op. +func systemctlIgnoringNotLoaded(action string) error { //nolint:gosec // BinaryName is a compile-time constant - out, err = exec.Command("systemctl", "disable", pkgconfig.BinaryName). - CombinedOutput() - if err != nil { - if !strings.Contains(string(out), "not loaded") { - return fmt.Errorf("systemctl disable: %s: %w", string(out), err) - } + out, err := exec.Command("systemctl", action, pkgconfig.BinaryName).CombinedOutput() + // A missing unit reports "not loaded" (stop) or "does not exist" (disable); + // both mean there is nothing to act on, so treat them as a no-op. + outStr := string(out) + if err != nil && !strings.Contains(outStr, "not loaded") && + !strings.Contains(outStr, "does not exist") { + return fmt.Errorf("systemctl %s: %s: %w", action, outStr, err) } + return nil +} +func removeServiceUnit() error { if err := os.Remove(serviceFilePath()); err != nil && !os.IsNotExist(err) { return fmt.Errorf("remove service file: %w", err) } @@ -356,6 +364,10 @@ func stopFallbackDaemon() error { return nil } + return killDaemonIfOurs(pidFile, pid) +} + +func killDaemonIfOurs(pidFile, pid string) error { running, err := command.IsRunning(pid) if err != nil || !running { // Process gone or check failed — stale PID file diff --git a/pkg/daemon/platform/workspace_watcher.go b/pkg/daemon/platform/workspace_watcher.go index 2b25f237b..ebdd39f99 100644 --- a/pkg/daemon/platform/workspace_watcher.go +++ b/pkg/daemon/platform/workspace_watcher.go @@ -72,48 +72,9 @@ func startWorkspaceWatcher(ctx context.Context, config watchConfig, onChange cha workspaceInformer := factory.Management().V1().DevsyWorkspaceInstances() instanceStore := newStore(self, config.Context, config.OwnerFilter, config.TsClient) _, err = workspaceInformer.Informer().AddEventHandler(cache.ResourceEventHandlerFuncs{ - AddFunc: func(obj any) { - instance, ok := obj.(*managementv1.DevsyWorkspaceInstance) - if !ok { - return - } - instanceStore.Add(instance) - if started.Load() { - onChange(instanceStore.List()) - } - }, - UpdateFunc: func(oldObj any, newObj any) { - oldInstance, ok := oldObj.(*managementv1.DevsyWorkspaceInstance) - if !ok { - return - } - newInstance, ok := newObj.(*managementv1.DevsyWorkspaceInstance) - if !ok { - return - } - instanceStore.Update(oldInstance, newInstance) - if started.Load() { - onChange(instanceStore.List()) - } - }, - DeleteFunc: func(obj any) { - instance, ok := obj.(*managementv1.DevsyWorkspaceInstance) - if !ok { - // check for DeletedFinalStateUnknown. Can happen if the informer misses the delete event - u, ok := obj.(cache.DeletedFinalStateUnknown) - if !ok { - return - } - instance, ok = u.Obj.(*managementv1.DevsyWorkspaceInstance) - if !ok { - return - } - } - instanceStore.Delete(instance) - if started.Load() { - onChange(instanceStore.List()) - } - }, + AddFunc: addInstanceHandler(instanceStore, started, onChange), + UpdateFunc: updateInstanceHandler(instanceStore, started, onChange), + DeleteFunc: deleteInstanceHandler(instanceStore, started, onChange), }) if err != nil { return err @@ -146,6 +107,63 @@ func startWorkspaceWatcher(ctx context.Context, config watchConfig, onChange cha } } +func addInstanceHandler( + store *instanceStore, started *atomic.Bool, onChange changeFn, +) func(any) { + return func(obj any) { + instance, ok := obj.(*managementv1.DevsyWorkspaceInstance) + if !ok { + return + } + store.Add(instance) + if started.Load() { + onChange(store.List()) + } + } +} + +func updateInstanceHandler( + store *instanceStore, started *atomic.Bool, onChange changeFn, +) func(any, any) { + return func(oldObj any, newObj any) { + oldInstance, ok := oldObj.(*managementv1.DevsyWorkspaceInstance) + if !ok { + return + } + newInstance, ok := newObj.(*managementv1.DevsyWorkspaceInstance) + if !ok { + return + } + store.Update(oldInstance, newInstance) + if started.Load() { + onChange(store.List()) + } + } +} + +func deleteInstanceHandler( + store *instanceStore, started *atomic.Bool, onChange changeFn, +) func(any) { + return func(obj any) { + instance, ok := obj.(*managementv1.DevsyWorkspaceInstance) + if !ok { + // check for DeletedFinalStateUnknown. Can happen if the informer misses the delete event + u, ok := obj.(cache.DeletedFinalStateUnknown) + if !ok { + return + } + instance, ok = u.Obj.(*managementv1.DevsyWorkspaceInstance) + if !ok { + return + } + } + store.Delete(instance) + if started.Load() { + onChange(store.List()) + } + } +} + type instanceStore struct { self *managementv1.Self context string @@ -352,58 +370,66 @@ func (s *instanceStore) updateWorkspaceLatencies(ctx context.Context) { wg.Add(1) go func(peer *ipnstate.PeerStatus, key string, instance *ProWorkspaceInstance) { defer wg.Done() + s.pingAndRecord(ctx, peer, key, instance) + }(peer, key, instance) + } - timeoutCtx, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - - log.Debugf("pinging workspace %s/%s", instance.GetNamespace(), instance.GetName()) - pingResult, err := s.tsClient.Ping(timeoutCtx, peer.TailscaleIPs[0], tailcfg.PingDisco) - if err != nil { - log.Debugf( - "Failed to ping workspace %s/%s: %v", - instance.GetNamespace(), - instance.GetName(), - err, - ) - return - } - if pingResult.Err != "" { - log.Debugf( - "Failed to ping workspace %s/%s: %v", - instance.GetNamespace(), - instance.GetName(), - pingResult.Err, - ) - return - } + wg.Wait() +} - // Determine connection type - connectionType := ConnectionTypeDirect - derpRegion := "" - if pingResult.DERPRegionID != 0 { - connectionType = ConnectionTypeDERP - derpRegion = pingResult.DERPRegionCode - } +func (s *instanceStore) pingAndRecord( + ctx context.Context, + peer *ipnstate.PeerStatus, + key string, + instance *ProWorkspaceInstance, +) { + timeoutCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() - s.metricsMu.Lock() - s.metrics[key] = append( - s.metrics[key], - WorkspaceNetworkMetrics{ - LatencyMs: pingResult.LatencySeconds * 1000, - ConnectionType: connectionType, - DERPRegion: derpRegion, - Timestamp: time.Now().Unix(), - }, - ) - // trim down to max samples if necessary - if len(s.metrics[key]) > s.maxMetricSamples { - s.metrics[key] = s.metrics[key][1:] - } - s.metricsMu.Unlock() - }(peer, key, instance) + log.Debugf("pinging workspace %s/%s", instance.GetNamespace(), instance.GetName()) + pingResult, err := s.tsClient.Ping(timeoutCtx, peer.TailscaleIPs[0], tailcfg.PingDisco) + if err != nil { + log.Debugf( + "Failed to ping workspace %s/%s: %v", + instance.GetNamespace(), + instance.GetName(), + err, + ) + return + } + if pingResult.Err != "" { + log.Debugf( + "Failed to ping workspace %s/%s: %v", + instance.GetNamespace(), + instance.GetName(), + pingResult.Err, + ) + return } - wg.Wait() + // Determine connection type + connectionType := ConnectionTypeDirect + derpRegion := "" + if pingResult.DERPRegionID != 0 { + connectionType = ConnectionTypeDERP + derpRegion = pingResult.DERPRegionCode + } + + s.metricsMu.Lock() + s.metrics[key] = append( + s.metrics[key], + WorkspaceNetworkMetrics{ + LatencyMs: pingResult.LatencySeconds * 1000, + ConnectionType: connectionType, + DERPRegion: derpRegion, + Timestamp: time.Now().Unix(), + }, + ) + // trim down to max samples if necessary + if len(s.metrics[key]) > s.maxMetricSamples { + s.metrics[key] = s.metrics[key][1:] + } + s.metricsMu.Unlock() } func getClientSet(config *rest.Config) (loftclient.Interface, error) { diff --git a/pkg/devcontainer/build/options.go b/pkg/devcontainer/build/options.go index 28c6e745b..60d1b9f67 100644 --- a/pkg/devcontainer/build/options.go +++ b/pkg/devcontainer/build/options.go @@ -134,6 +134,20 @@ func NewOptions(params NewOptionsParams) (*BuildOptions, error) { } // other options + appendImages(buildOptions, params, prebuildHash) + buildOptions.Context = config.GetContextPath(params.ParsedConfig.Config) + + // add build arg + if buildOptions.BuildArgs == nil { + buildOptions.BuildArgs = map[string]string{} + } + + applyCacheArgs(buildOptions, params) + + return buildOptions, nil +} + +func appendImages(buildOptions *BuildOptions, params NewOptionsParams, prebuildHash string) { if params.ImageName != "" { buildOptions.Images = append(buildOptions.Images, params.ImageName) } @@ -146,13 +160,9 @@ func NewOptions(params NewOptionsParams) (*BuildOptions, error) { for _, prebuildRepository := range params.Options.PrebuildRepositories { buildOptions.Images = append(buildOptions.Images, prebuildRepository+":"+prebuildHash) } - buildOptions.Context = config.GetContextPath(params.ParsedConfig.Config) - - // add build arg - if buildOptions.BuildArgs == nil { - buildOptions.BuildArgs = map[string]string{} - } +} +func applyCacheArgs(buildOptions *BuildOptions, params NewOptionsParams) { // define cache args if params.Options.RegistryCache != "" { buildOptions.CacheFrom = []string{ @@ -176,8 +186,6 @@ func NewOptions(params NewOptionsParams) (*BuildOptions, error) { if len(buildOptions.CacheFrom) == 0 { buildOptions.BuildArgs["BUILDKIT_INLINE_CACHE"] = "1" } - - return buildOptions, nil } func GetBuildArgsAndTarget( diff --git a/pkg/devcontainer/buildkit/buildkit.go b/pkg/devcontainer/buildkit/buildkit.go index 1566f703e..491c432f0 100644 --- a/pkg/devcontainer/buildkit/buildkit.go +++ b/pkg/devcontainer/buildkit/buildkit.go @@ -10,6 +10,7 @@ import ( "github.com/devsy-org/devsy/pkg/devcontainer/build" "github.com/devsy-org/devsy/pkg/docker" + "github.com/docker/cli/cli/config/configfile" buildkit "github.com/moby/buildkit/client" "github.com/moby/buildkit/session" "github.com/moby/buildkit/session/auth/authprovider" @@ -23,41 +24,37 @@ func Build( platform string, options *build.BuildOptions, ) error { - dockerConfig, err := docker.LoadDockerConfig() + solveOptions, err := buildkitSolveOpt(platform, options) if err != nil { return err } - // cache from - cacheFrom, err := ParseCacheEntry(options.CacheFrom) + pw, err := NewPrinter(ctx, writer) if err != nil { return err } - cacheTo, err := ParseCacheEntry(options.CacheTo) + + // build + _, err = client.Solve(ctx, nil, solveOptions, pw.Status()) + return err +} + +func buildkitSolveOpt(platform string, options *build.BuildOptions) (buildkit.SolveOpt, error) { + dockerConfig, err := docker.LoadDockerConfig() if err != nil { - return err + return buildkit.SolveOpt{}, err } - // is context stream? - attachable := []session.Attachable{} - attachable = append( - attachable, - authprovider.NewDockerAuthProvider( - authprovider.DockerAuthProviderConfig{ - AuthConfigProvider: authprovider.LoadAuthConfig(dockerConfig), - }, - ), - ) - - secretsAttachable, err := buildSecretsAttachable(options.BuildSecrets) + cacheFrom, cacheTo, err := setupCache(options) if err != nil { - return err + return buildkit.SolveOpt{}, err } - if secretsAttachable != nil { - attachable = append(attachable, secretsAttachable) + + attachable, err := buildkitAttachables(dockerConfig, options) + if err != nil { + return buildkit.SolveOpt{}, err } - // create solve options solveOptions := buildkit.SolveOpt{ Frontend: "dockerfile.v0", FrontendAttrs: map[string]string{ @@ -67,6 +64,7 @@ func Build( Session: attachable, CacheImports: cacheFrom, CacheExports: cacheTo, + LocalMounts: map[string]fsutil.FS{}, } // set options target @@ -79,8 +77,46 @@ func Build( solveOptions.FrontendAttrs["platform"] = platform } - // add context and dockerfile to local mounts - solveOptions.LocalMounts = map[string]fsutil.FS{} + if err := buildkitLocalMounts(&solveOptions, options); err != nil { + return buildkit.SolveOpt{}, err + } + if err := buildkitMultiContexts(&solveOptions, options); err != nil { + return buildkit.SolveOpt{}, err + } + + buildkitExports(&solveOptions, options) + buildkitFrontendExtras(&solveOptions, options) + + // add additional build cli options + // TODO: convert options.CliOpts into a solveOptions.FrontendAttr + + return solveOptions, nil +} + +func buildkitAttachables( + dockerConfig *configfile.ConfigFile, + options *build.BuildOptions, +) ([]session.Attachable, error) { + attachable := []session.Attachable{ + authprovider.NewDockerAuthProvider( + authprovider.DockerAuthProviderConfig{ + AuthConfigProvider: authprovider.LoadAuthConfig(dockerConfig), + }, + ), + } + + secretsAttachable, err := buildSecretsAttachable(options.BuildSecrets) + if err != nil { + return nil, err + } + if secretsAttachable != nil { + attachable = append(attachable, secretsAttachable) + } + + return attachable, nil +} + +func buildkitLocalMounts(solveOptions *buildkit.SolveOpt, options *build.BuildOptions) error { contextFS, err := fsutil.NewFS(options.Context) if err != nil { return fmt.Errorf("failed to create build context fs: %w", err) @@ -91,8 +127,10 @@ func Build( return fmt.Errorf("failed to create dockerfile fs: %w", err) } solveOptions.LocalMounts["dockerfile"] = dockerfileFS + return nil +} - // multi contexts +func buildkitMultiContexts(solveOptions *buildkit.SolveOpt, options *build.BuildOptions) error { for k, v := range options.Contexts { st, err := os.Stat(v) if err != nil { @@ -112,7 +150,10 @@ func Build( solveOptions.LocalMounts[localName] = contextMountFS solveOptions.FrontendAttrs["context:"+k] = "local:" + localName } + return nil +} +func buildkitExports(solveOptions *buildkit.SolveOpt, options *build.BuildOptions) { // load? if options.Load { solveOptions.Exports = append(solveOptions.Exports, buildkit.ExportEntry{ @@ -131,7 +172,9 @@ func Build( }, }) } +} +func buildkitFrontendExtras(solveOptions *buildkit.SolveOpt, options *build.BuildOptions) { // add labels for k, v := range options.Labels { solveOptions.FrontendAttrs["label:"+k] = v @@ -141,20 +184,4 @@ func Build( for key, value := range options.BuildArgs { solveOptions.FrontendAttrs["build-arg:"+key] = value } - - // add additional build cli options - // TODO: convert options.CliOpts into a solveOptions.FrontendAttr - - pw, err := NewPrinter(ctx, writer) - if err != nil { - return err - } - - // build - _, err = client.Solve(ctx, nil, solveOptions, pw.Status()) - if err != nil { - return err - } - - return nil } diff --git a/pkg/devcontainer/buildkit/cache.go b/pkg/devcontainer/buildkit/cache.go index 89d170e2f..25a398736 100644 --- a/pkg/devcontainer/buildkit/cache.go +++ b/pkg/devcontainer/buildkit/cache.go @@ -18,30 +18,12 @@ func ParseCacheEntry(in []string) ([]client.CacheOptionsEntry, error) { return nil, err } if isRefOnlyFormat(fields) { - for _, field := range fields { - imports = append(imports, client.CacheOptionsEntry{ - Type: "registry", - Attrs: map[string]string{"ref": field}, - }) - } + imports = append(imports, refOnlyEntries(fields)...) continue } - im := client.CacheOptionsEntry{ - Attrs: map[string]string{}, - } - for _, field := range fields { - parts := strings.SplitN(field, "=", 2) - if len(parts) != 2 { - return nil, fmt.Errorf("invalid value %s", field) - } - key := strings.ToLower(parts[0]) - value := parts[1] - switch key { - case "type": - im.Type = value - default: - im.Attrs[key] = value - } + im, err := parseCacheFields(fields) + if err != nil { + return nil, err } if im.Type == "" { return nil, fmt.Errorf("type required from: %q", in) @@ -54,6 +36,38 @@ func ParseCacheEntry(in []string) ([]client.CacheOptionsEntry, error) { return imports, nil } +func refOnlyEntries(fields []string) []client.CacheOptionsEntry { + entries := make([]client.CacheOptionsEntry, 0, len(fields)) + for _, field := range fields { + entries = append(entries, client.CacheOptionsEntry{ + Type: "registry", + Attrs: map[string]string{"ref": field}, + }) + } + return entries +} + +func parseCacheFields(fields []string) (client.CacheOptionsEntry, error) { + im := client.CacheOptionsEntry{ + Attrs: map[string]string{}, + } + for _, field := range fields { + parts := strings.SplitN(field, "=", 2) + if len(parts) != 2 { + return client.CacheOptionsEntry{}, fmt.Errorf("invalid value %s", field) + } + key := strings.ToLower(parts[0]) + value := parts[1] + switch key { + case "type": + im.Type = value + default: + im.Attrs[key] = value + } + } + return im, nil +} + func isRefOnlyFormat(in []string) bool { for _, v := range in { if strings.Contains(v, "=") { diff --git a/pkg/devcontainer/buildkit/remote.go b/pkg/devcontainer/buildkit/remote.go index d75ded84f..7d999cc97 100644 --- a/pkg/devcontainer/buildkit/remote.go +++ b/pkg/devcontainer/buildkit/remote.go @@ -73,10 +73,6 @@ func BuildRemote(ctx context.Context, opts BuildRemoteOptions) (*config.BuildInf return buildInfo, nil } - if err := remote.CheckPushPermission(ref, keychain, http.DefaultTransport); err != nil { - return nil, fmt.Errorf("pushing %s is not allowed: %w", ref, err) - } - solveOpts, err := prepareSolveOptions(ref, keychain, imageName, opts) if err != nil { return nil, err @@ -91,7 +87,27 @@ func BuildRemote(ctx context.Context, opts BuildRemoteOptions) (*config.BuildInf return nil, err } - imageDetails, err := getImageDetails(ctx, ref, opts.TargetArch, keychain) + return buildFinalResult(ctx, buildFinalResultParams{ + Ref: ref, + Keychain: keychain, + ImageName: imageName, + Opts: opts, + }) +} + +type buildFinalResultParams struct { + Ref name.Reference + Keychain authn.Keychain + ImageName string + Opts BuildRemoteOptions +} + +func buildFinalResult( + ctx context.Context, + params buildFinalResultParams, +) (*config.BuildInfo, error) { + opts := params.Opts + imageDetails, err := getImageDetails(ctx, params.Ref, opts.TargetArch, params.Keychain) if err != nil { return nil, fmt.Errorf("get image details: %w", err) } @@ -104,7 +120,7 @@ func BuildRemote(ctx context.Context, opts BuildRemoteOptions) (*config.BuildInf return &config.BuildInfo{ ImageDetails: imageDetails, ImageMetadata: imageMetadata, - ImageName: imageName, + ImageName: params.ImageName, PrebuildHash: opts.PrebuildHash, RegistryCache: opts.Options.RegistryCache, Tags: opts.Options.Tag, @@ -256,18 +272,14 @@ func prepareSolveOptions( imageName string, opts BuildRemoteOptions, ) (client.SolveOpt, error) { - authSession, err := setupRegistryAuth(ref, keychain) - if err != nil { - return client.SolveOpt{}, err + if err := remote.CheckPushPermission(ref, keychain, http.DefaultTransport); err != nil { + return client.SolveOpt{}, fmt.Errorf("pushing %s is not allowed: %w", ref, err) } - secretsAttachable, err := buildSecretsAttachable(opts.Options.BuildSecrets) + authSession, err := buildRemoteSession(ref, keychain, opts) if err != nil { return client.SolveOpt{}, err } - if secretsAttachable != nil { - authSession = append(authSession, secretsAttachable) - } buildOpts, err := build.NewOptions(build.NewOptionsParams{ DockerfilePath: opts.DockerfilePath, @@ -316,6 +328,27 @@ func prepareSolveOptions( return solveOpts, nil } +func buildRemoteSession( + ref name.Reference, + keychain authn.Keychain, + opts BuildRemoteOptions, +) ([]session.Attachable, error) { + authSession, err := setupRegistryAuth(ref, keychain) + if err != nil { + return nil, err + } + + secretsAttachable, err := buildSecretsAttachable(opts.Options.BuildSecrets) + if err != nil { + return nil, err + } + if secretsAttachable != nil { + authSession = append(authSession, secretsAttachable) + } + + return authSession, nil +} + func setupCache( buildOpts *build.BuildOptions, ) ([]client.CacheOptionsEntry, []client.CacheOptionsEntry, error) { diff --git a/pkg/devcontainer/config/config.go b/pkg/devcontainer/config/config.go index 6ba630ed3..ac2faccc1 100644 --- a/pkg/devcontainer/config/config.go +++ b/pkg/devcontainer/config/config.go @@ -587,28 +587,35 @@ func ParseMount(str string) Mount { splitted := strings.SplitSeq(str, ",") for split := range splitted { splitted2 := strings.Split(split, "=") - key := splitted2[0] - switch key { - case "src", "source": - retMount.Source = splitted2[1] - case "workspaceMount": - retMount.Source = splitted2[1] - case "workspaceFolder": - retMount.Target = splitted2[1] - case "dst", "destination", "target": - retMount.Target = splitted2[1] - case "type": - retMount.Type = splitted2[1] - case "external": - retMount.External, _ = strconv.ParseBool(splitted2[1]) - default: + if len(splitted2) < 2 { retMount.Other = append(retMount.Other, split) + continue } + applyMountField(&retMount, splitted2[0], splitted2, split) } return retMount } +func applyMountField(retMount *Mount, key string, splitted2 []string, split string) { + switch key { + case "src", "source": + retMount.Source = splitted2[1] + case "workspaceMount": + retMount.Source = splitted2[1] + case "workspaceFolder": + retMount.Target = splitted2[1] + case "dst", "destination", "target": + retMount.Target = splitted2[1] + case "type": + retMount.Type = splitted2[1] + case "external": + retMount.External, _ = strconv.ParseBool(splitted2[1]) + default: + retMount.Other = append(retMount.Other, split) + } +} + func (m *Mount) UnmarshalJSON(data []byte) error { var jsonObj any err := json.Unmarshal(data, &jsonObj) @@ -620,33 +627,36 @@ func (m *Mount) UnmarshalJSON(data []byte) error { *m = ParseMount(obj) return nil case map[string]any: - sourceStr, ok := obj["source"].(string) - if ok { - m.Source = sourceStr - } - targetStr, ok := obj["target"].(string) - if ok { - m.Target = targetStr - } - typeStr, ok := obj["type"].(string) - if ok { - m.Type = typeStr - } - externalStr, ok := obj["external"].(bool) - if ok { - m.External = externalStr - } - otherInterface, ok := obj["other"].([]any) - if ok { - otherStr := make([]string, len(otherInterface)) - for i := range otherInterface { - otherStr[i] = otherInterface[i].(string) + return m.unmarshalMountObject(obj) + } + return types.ErrUnsupportedType +} + +func (m *Mount) unmarshalMountObject(obj map[string]any) error { + if sourceStr, ok := obj["source"].(string); ok { + m.Source = sourceStr + } + if targetStr, ok := obj["target"].(string); ok { + m.Target = targetStr + } + if typeStr, ok := obj["type"].(string); ok { + m.Type = typeStr + } + if externalStr, ok := obj["external"].(bool); ok { + m.External = externalStr + } + if otherInterface, ok := obj["other"].([]any); ok { + otherStr := make([]string, len(otherInterface)) + for i := range otherInterface { + str, ok := otherInterface[i].(string) + if !ok { + return types.ErrUnsupportedType } - m.Other = otherStr + otherStr[i] = str } - return nil + m.Other = otherStr } - return types.ErrUnsupportedType + return nil } // hasFlag returns true if any name appears in m.Other as a bare token or diff --git a/pkg/devcontainer/config/merge.go b/pkg/devcontainer/config/merge.go index d633f3c19..0e4721fe7 100644 --- a/pkg/devcontainer/config/merge.go +++ b/pkg/devcontainer/config/merge.go @@ -52,12 +52,7 @@ func MergeConfiguration( imageMetadataEntries = []*ImageMetadata{userMetadata} } - customizations := map[string][]any{} - for _, imageMetadata := range imageMetadataEntries { - for k, v := range imageMetadata.Customizations { - customizations[k] = append(customizations[k], v) - } - } + customizations := collectCustomizations(imageMetadataEntries) copiedConfig := CloneDevContainerConfig(config) @@ -165,19 +160,33 @@ func MergeConfiguration( mergedConfig.HostRequirements = mergeHostRequirements(reversed) if mergedConfig.ShutdownAction == "" { - switch { - case copiedConfig.ShutdownAction != "": - mergedConfig.ShutdownAction = copiedConfig.ShutdownAction - case len(copiedConfig.DockerComposeFile) > 0: - mergedConfig.ShutdownAction = ShutdownActionStopCompose - default: - mergedConfig.ShutdownAction = ShutdownActionStopContainer - } + mergedConfig.ShutdownAction = defaultShutdownAction(copiedConfig) } return mergedConfig, nil } +func collectCustomizations(entries []*ImageMetadata) map[string][]any { + customizations := map[string][]any{} + for _, imageMetadata := range entries { + for k, v := range imageMetadata.Customizations { + customizations[k] = append(customizations[k], v) + } + } + return customizations +} + +func defaultShutdownAction(copiedConfig *DevContainerConfig) string { + switch { + case copiedConfig.ShutdownAction != "": + return copiedConfig.ShutdownAction + case len(copiedConfig.DockerComposeFile) > 0: + return ShutdownActionStopCompose + default: + return ShutdownActionStopContainer + } +} + func mergeOtherPortsAttributes(entries []*ImageMetadata) *PortAttribute { for _, entry := range entries { if entry.OtherPortsAttributes != nil { diff --git a/pkg/devcontainer/config/parse_test.go b/pkg/devcontainer/config/parse_test.go index fdce8c7a3..a28a3d76a 100644 --- a/pkg/devcontainer/config/parse_test.go +++ b/pkg/devcontainer/config/parse_test.go @@ -166,14 +166,8 @@ func TestFindDevContainerConfigs(t *testing.T) { } } -func TestListDevContainerIDs(t *testing.T) { - tmpDir := t.TempDir() - - configs := map[string]string{ - ".devcontainer/python/devcontainer.json": `{"name":"Python"}`, - ".devcontainer/node/devcontainer.json": `{"name":"Node"}`, - } - +func writeNamedConfigs(t *testing.T, tmpDir string, configs map[string]string) { + t.Helper() for cfg, content := range configs { fullPath := filepath.Join(tmpDir, cfg) // #nosec G301 -- TODO Consider using a more secure permission setting and ownership if needed. @@ -185,6 +179,17 @@ func TestListDevContainerIDs(t *testing.T) { t.Fatal(err) } } +} + +func TestListDevContainerIDs(t *testing.T) { + tmpDir := t.TempDir() + + configs := map[string]string{ + ".devcontainer/python/devcontainer.json": `{"name":"Python"}`, + ".devcontainer/node/devcontainer.json": `{"name":"Node"}`, + } + + writeNamedConfigs(t, tmpDir, configs) ids, err := ListDevContainerIDs(tmpDir) if err != nil { diff --git a/pkg/devcontainer/config/result.go b/pkg/devcontainer/config/result.go index 4263f4d17..0b75bf554 100644 --- a/pkg/devcontainer/config/result.go +++ b/pkg/devcontainer/config/result.go @@ -87,10 +87,8 @@ func GetRemoteUser(result *Result) string { return result.MergedConfig.RemoteUser } - if result.ContainerDetails != nil && result.ContainerDetails.Config.Labels != nil { - if userLabel := result.ContainerDetails.Config.Labels[UserLabel]; userLabel != "" { - return userLabel - } + if userLabel := userFromContainerLabel(result); userLabel != "" { + return userLabel } if result.MergedConfig != nil && result.MergedConfig.ContainerUser != "" { @@ -100,6 +98,13 @@ func GetRemoteUser(result *Result) string { return "root" } +func userFromContainerLabel(result *Result) string { + if result.ContainerDetails == nil || result.ContainerDetails.Config.Labels == nil { + return "" + } + return result.ContainerDetails.Config.Labels[UserLabel] +} + func GetDevsyCustomizations(parsedConfig *DevContainerConfig) *DevsyCustomizations { if parsedConfig.Customizations == nil || parsedConfig.Customizations[pkgconfig.BinaryName] == nil { diff --git a/pkg/devcontainer/config/substitute.go b/pkg/devcontainer/config/substitute.go index 9ceed5367..e711c349c 100644 --- a/pkg/devcontainer/config/substitute.go +++ b/pkg/devcontainer/config/substitute.go @@ -72,11 +72,7 @@ func Substitute(substitutionCtx *SubstitutionContext, config any, out any) error // if windows adjust env isWindows := runtime.GOOS == "windows" if isWindows { - newEnv := map[string]string{} - for k, v := range substitutionCtx.Env { - newEnv[strings.ToLower(k)] = v - } - substitutionCtx.Env = newEnv + substitutionCtx.Env = lowercaseEnvKeys(substitutionCtx.Env) } if substitutionCtx.ContainerWorkspaceFolder != "" { @@ -93,13 +89,7 @@ func Substitute(substitutionCtx *SubstitutionContext, config any, out any) error fullReplace := func(match, variable string, args []string) string { return replaceWithContext(isWindows, substitutionCtx, match, variable, args) } - preFieldValues := map[string]any{} - for _, key := range preContainerFields { - if fieldVal, ok := newVal[key]; ok { - preFieldValues[key] = substitute0(fieldVal, restrictedReplace(fullReplace)) - delete(newVal, key) - } - } + preFieldValues := extractPreContainerFields(newVal, fullReplace) // Full substitution for remaining fields. retVal := substitute0(newVal, fullReplace) @@ -117,6 +107,28 @@ func Substitute(substitutionCtx *SubstitutionContext, config any, out any) error return nil } +func lowercaseEnvKeys(env map[string]string) map[string]string { + newEnv := map[string]string{} + for k, v := range env { + newEnv[strings.ToLower(k)] = v + } + return newEnv +} + +func extractPreContainerFields( + newVal map[string]any, + fullReplace ReplaceFunction, +) map[string]any { + preFieldValues := map[string]any{} + for _, key := range preContainerFields { + if fieldVal, ok := newVal[key]; ok { + preFieldValues[key] = substitute0(fieldVal, restrictedReplace(fullReplace)) + delete(newVal, key) + } + } + return preFieldValues +} + func SubstituteContainerEnv(containerEnv map[string]string, config any, out any) error { newVal := map[string]any{} err := convert(config, &newVal) @@ -158,39 +170,36 @@ func replaceWithContext( ) string { switch variable { case "devcontainerId": - if substitutionCtx.DevContainerID != "" { - return substitutionCtx.DevContainerID - } - return match + return valueOrMatch(substitutionCtx.DevContainerID, match) case varLocalEnv: return lookupValue(isWindows, substitutionCtx.Env, args, match) case varLocalWorkspaceFolder: - if substitutionCtx.LocalWorkspaceFolder != "" { - return substitutionCtx.LocalWorkspaceFolder - } - return match + return valueOrMatch(substitutionCtx.LocalWorkspaceFolder, match) case "localWorkspaceFolderBasename": - if substitutionCtx.LocalWorkspaceFolder != "" { - return filepath.Base(substitutionCtx.LocalWorkspaceFolder) - } - return match + return baseOrMatch(substitutionCtx.LocalWorkspaceFolder, match) case "containerWorkspaceFolder": - if substitutionCtx.ContainerWorkspaceFolder != "" { - return substitutionCtx.ContainerWorkspaceFolder - } - return match + return valueOrMatch(substitutionCtx.ContainerWorkspaceFolder, match) case "containerWorkspaceFolderBasename": - if substitutionCtx.ContainerWorkspaceFolder != "" { - return filepath.Base(substitutionCtx.ContainerWorkspaceFolder) - } - return match - case containerEnvField: - return match + return baseOrMatch(substitutionCtx.ContainerWorkspaceFolder, match) default: return match } } +func valueOrMatch(value, match string) string { + if value != "" { + return value + } + return match +} + +func baseOrMatch(value, match string) string { + if value != "" { + return filepath.Base(value) + } + return match +} + // restrictedReplace wraps a ReplaceFunction to preserve container-scoped // variables that cannot be resolved at host substitution time as literals. // diff --git a/pkg/devcontainer/feature/extend.go b/pkg/devcontainer/feature/extend.go index edfaca7d3..db306e351 100644 --- a/pkg/devcontainer/feature/extend.go +++ b/pkg/devcontainer/feature/extend.go @@ -312,9 +312,9 @@ func fetchFeatures( secretOpts *SecretOptions, lockMode lockfileMode, ) ([]*config.FeatureSet, error) { - var lock *lockfileState - if !lockMode.disabled { - lock = newLockfileState(devContainerConfig) + lock, err := prepareLock(devContainerConfig, lockMode) + if err != nil { + return nil, err } processor := &featureProcessor{ devContainerConfig: devContainerConfig, @@ -323,12 +323,6 @@ func fetchFeatures( lock: lock, } - if lockMode.write && lockMode.frozen { - if err := lock.checkFrozenPrecondition(devContainerConfig); err != nil { - return nil, err - } - } - userFeatures, err := getUserFeatures(processor, devContainerConfig) if err != nil { return nil, err @@ -356,6 +350,24 @@ func fetchFeatures( return featureSets, nil } +func prepareLock( + devContainerConfig *config.DevContainerConfig, + lockMode lockfileMode, +) (*lockfileState, error) { + var lock *lockfileState + if !lockMode.disabled { + lock = newLockfileState(devContainerConfig) + } + + if lockMode.write && lockMode.frozen { + if err := lock.checkFrozenPrecondition(devContainerConfig); err != nil { + return nil, err + } + } + + return lock, nil +} + func getUserFeatures( processor *featureProcessor, devContainerConfig *config.DevContainerConfig, diff --git a/pkg/devcontainer/feature/features.go b/pkg/devcontainer/feature/features.go index ba7fb9a35..abbaedef6 100644 --- a/pkg/devcontainer/feature/features.go +++ b/pkg/devcontainer/feature/features.go @@ -624,17 +624,15 @@ func processDirectTarFeature( featureExtractedFolder := filepath.Join(featureFolder, "extracted") // Check cache — verify integrity if present. - _, statErr := os.Stat(featureExtractedFolder) - if statErr == nil && !forceDownload { - if verifyCacheIntegrity(featureFolder, id) { - log.Debugf("direct tar feature already cached: folder=%s", featureExtractedFolder) - cachedIntegrity := tarballIntegrity(featureFolder) - if err := verifyPinnedDigest(id, cachedIntegrity, pinnedIntegrity); err != nil { - return "", "", err - } - return featureExtractedFolder, cachedIntegrity, nil - } - _ = os.RemoveAll(featureFolder) + cached, err := checkDirectTarCache(checkDirectTarCacheParams{ + featureFolder: featureFolder, + extractedFolder: featureExtractedFolder, + id: id, + forceDownload: forceDownload, + pinnedIntegrity: pinnedIntegrity, + }) + if err != nil || cached.hit { + return cached.folder, cached.integrity, err } // Download feature tarball. @@ -664,6 +662,43 @@ func processDirectTarFeature( return featureExtractedFolder, downloadIntegrity, nil } +type checkDirectTarCacheParams struct { + featureFolder string + extractedFolder string + id string + forceDownload bool + pinnedIntegrity string +} + +type directTarCacheResult struct { + folder string + integrity string + hit bool +} + +func checkDirectTarCache(params checkDirectTarCacheParams) (directTarCacheResult, error) { + _, statErr := os.Stat(params.extractedFolder) + if statErr != nil || params.forceDownload { + return directTarCacheResult{}, nil + } + + if !verifyCacheIntegrity(params.featureFolder, params.id) { + _ = os.RemoveAll(params.featureFolder) + return directTarCacheResult{}, nil + } + + log.Debugf("direct tar feature already cached: folder=%s", params.extractedFolder) + cachedIntegrity := tarballIntegrity(params.featureFolder) + if err := verifyPinnedDigest(params.id, cachedIntegrity, params.pinnedIntegrity); err != nil { + return directTarCacheResult{}, err + } + return directTarCacheResult{ + folder: params.extractedFolder, + integrity: cachedIntegrity, + hit: true, + }, nil +} + // tarballIntegrity returns the sha256: digest of a cached feature tarball, // or "" when it cannot be computed. func tarballIntegrity(featureFolder string) string { diff --git a/pkg/devcontainer/prebuild.go b/pkg/devcontainer/prebuild.go index 2dce58fc3..ed8fa8a39 100644 --- a/pkg/devcontainer/prebuild.go +++ b/pkg/devcontainer/prebuild.go @@ -33,23 +33,7 @@ func (r *runner) Build(ctx context.Context, options provider.BuildOptions) (stri return "", fmt.Errorf("build image: %w", err) } - // have a fallback value for PrebuildHash - // in some cases it may be empty, and this would lead to - // invalid reference format during image pushing. - if buildInfo.PrebuildHash == "" { - buildInfo.PrebuildHash = "latest" - } - - // prebuild already exists - var prebuildImage string - switch { - case options.Repository != "": - prebuildImage = options.Repository + ":" + buildInfo.PrebuildHash - case prebuildRepo != "": - prebuildImage = prebuildRepo + ":" + buildInfo.PrebuildHash - default: - prebuildImage = build.GetImageName(r.localWorkspaceFolder, buildInfo.PrebuildHash) - } + prebuildImage := r.determinePrebuildImage(options, prebuildRepo, buildInfo) if buildInfo.ImageName == prebuildImage { return buildInfo.ImageName, nil @@ -68,56 +52,119 @@ func (r *runner) Build(ctx context.Context, options provider.BuildOptions) (stri return prebuildImage, nil } - if isDockerComposeConfig(substitutedConfig.Config) { - if err := dockerDriver.TagDevContainer( + if err := r.pushPrebuildImage(ctx, pushPrebuildImageParams{ + dockerDriver: dockerDriver, + substitutedConfig: substitutedConfig, + buildInfo: buildInfo, + prebuildImage: prebuildImage, + options: options, + prebuildRepo: prebuildRepo, + }); err != nil { + return "", err + } + + return prebuildImage, nil +} + +// determinePrebuildImage computes the prebuild image reference, defaulting an +// empty PrebuildHash to "latest" to avoid an invalid reference format on push. +func (r *runner) determinePrebuildImage( + options provider.BuildOptions, + prebuildRepo string, + buildInfo *config.BuildInfo, +) string { + if buildInfo.PrebuildHash == "" { + buildInfo.PrebuildHash = "latest" + } + + switch { + case options.Repository != "": + return options.Repository + ":" + buildInfo.PrebuildHash + case prebuildRepo != "": + return prebuildRepo + ":" + buildInfo.PrebuildHash + default: + return build.GetImageName(r.localWorkspaceFolder, buildInfo.PrebuildHash) + } +} + +type pushPrebuildImageParams struct { + dockerDriver driver.ImageDriver + substitutedConfig *config.SubstitutedConfig + buildInfo *config.BuildInfo + prebuildImage string + options provider.BuildOptions + prebuildRepo string +} + +func (r *runner) pushPrebuildImage(ctx context.Context, params pushPrebuildImageParams) error { + if isDockerComposeConfig(params.substitutedConfig.Config) { + if err := params.dockerDriver.TagDevContainer( ctx, - buildInfo.ImageName, - prebuildImage, + params.buildInfo.ImageName, + params.prebuildImage, ); err != nil { - return "", fmt.Errorf("tag image: %w", err) + return fmt.Errorf("tag image: %w", err) } } // if no repository is specified, skip push (mirrors devcontainer CLI behavior) - if options.Repository == "" && prebuildRepo == "" { - return prebuildImage, nil + if params.options.Repository == "" && params.prebuildRepo == "" { + return nil } // check if we can push image - if err := image.CheckPushPermissions(ctx, prebuildImage); err != nil { - return "", fmt.Errorf( + if err := image.CheckPushPermissions(ctx, params.prebuildImage); err != nil { + return fmt.Errorf( "cannot push to repository %s. Make sure you are logged into the registry "+ "and credentials are available. (Error: %w)", - prebuildImage, + params.prebuildImage, err, ) } + return tagAndPushImages(ctx, params.dockerDriver, params.prebuildImage, params.buildInfo.Tags) +} + +func tagAndPushImages( + ctx context.Context, + dockerDriver driver.ImageDriver, + prebuildImage string, + tags []string, +) error { // Setup all image tags (prebuild and any user defined tags) imageRefs := []string{prebuildImage} - imageRepoName := strings.Split(prebuildImage, ":") - if buildInfo.Tags != nil { - for _, tag := range buildInfo.Tags { - imageRefs = append(imageRefs, imageRepoName[0]+":"+tag) - } + imageRepoName := stripImageTag(prebuildImage) + for _, tag := range tags { + imageRefs = append(imageRefs, imageRepoName+":"+tag) } // tag the image for _, imageRef := range imageRefs { if err := dockerDriver.TagDevContainer(ctx, prebuildImage, imageRef); err != nil { - return "", fmt.Errorf("tag image: %w", err) + return fmt.Errorf("tag image: %w", err) } } // push the image to the registry for _, imageRef := range imageRefs { if err := dockerDriver.PushDevContainer(ctx, imageRef); err != nil { - return "", fmt.Errorf("push image: %w", err) + return fmt.Errorf("push image: %w", err) } } - return prebuildImage, nil + return nil +} + +// stripImageTag returns the image reference without its trailing tag suffix, +// preserving any registry port (host:port) and repository path. A tag +// separator only counts when it appears after the last path separator. +func stripImageTag(image string) string { + lastSlash := strings.LastIndex(image, "/") + if colon := strings.LastIndex(image, ":"); colon > lastSlash { + return image[:colon] + } + return image } func getPrebuildRepository(substitutedConfig *config.SubstitutedConfig) string { diff --git a/pkg/devcontainer/setup/setup.go b/pkg/devcontainer/setup/setup.go index 286934de6..785c3557d 100644 --- a/pkg/devcontainer/setup/setup.go +++ b/pkg/devcontainer/setup/setup.go @@ -603,19 +603,8 @@ func setupPlatformGitCredentials( } // setup platform git user - if platformOptions.UserCredentials.GitUser != "" && - platformOptions.UserCredentials.GitEmail != "" { - gitUser, err := gitcredentials.GetUser(ctx, userName, "") - if err == nil && gitUser.Name == "" && gitUser.Email == "" { - log.Info("Setup workspace git user and email") - err := gitcredentials.SetUser(ctx, userName, &gitcredentials.GitUser{ - Name: platformOptions.UserCredentials.GitUser, - Email: platformOptions.UserCredentials.GitEmail, - }) - if err != nil { - return fmt.Errorf("set git user: %w", err) - } - } + if err := setupPlatformGitUser(ctx, userName, platformOptions); err != nil { + return err } // setup platform git http credentials @@ -633,6 +622,31 @@ func setupPlatformGitCredentials( return nil } +func setupPlatformGitUser( + ctx context.Context, + userName string, + platformOptions *devsy.PlatformOptions, +) error { + if platformOptions.UserCredentials.GitUser == "" || + platformOptions.UserCredentials.GitEmail == "" { + return nil + } + + gitUser, err := gitcredentials.GetUser(ctx, userName, "") + if err == nil && gitUser.Name == "" && gitUser.Email == "" { + log.Info("Setup workspace git user and email") + err := gitcredentials.SetUser(ctx, userName, &gitcredentials.GitUser{ + Name: platformOptions.UserCredentials.GitUser, + Email: platformOptions.UserCredentials.GitEmail, + }) + if err != nil { + return fmt.Errorf("set git user: %w", err) + } + } + + return nil +} + func setupPlatformGitHTTPCredentials( ctx context.Context, userName string, @@ -678,6 +692,20 @@ func setupPlatformGitSSHKeys( _ = copy2.Chown(sshFolder, userName) // delete previous keys + if err := removeStalePlatformSSHKeys( + sshFolder, + len(platformOptions.UserCredentials.GitSsh), + ); err != nil { + return err + } + + // write new keys + writePlatformSSHKeys(sshFolder, userName, platformOptions.UserCredentials.GitSsh) + + return nil +} + +func removeStalePlatformSSHKeys(sshFolder string, keyCount int) error { files, err := os.ReadDir(sshFolder) if err != nil { return err @@ -692,7 +720,7 @@ func setupPlatformGitSSHKeys( if err != nil { continue } - if index >= len(platformOptions.UserCredentials.GitSsh) { + if index < keyCount { continue } @@ -701,29 +729,40 @@ func setupPlatformGitSSHKeys( log.Warnf("Error removing previous platform git ssh key: %v", err) } } + return nil +} - // write new keys - for i, key := range platformOptions.UserCredentials.GitSsh { +func writePlatformSSHKeys( + sshFolder, userName string, + keys []devsy.PlatformGitSshCredentials, +) { + for i, key := range keys { fileName := filepath.Join(sshFolder, fmt.Sprintf("platform_git_ssh_%d", i)) - - // base64 decode before writing to file - decoded, err := base64.StdEncoding.DecodeString(key.Key) - if err != nil { - log.Warnf("Error decoding platform git ssh key: %v", err) - continue - } - err = os.WriteFile(fileName, decoded, 0o600) - if err != nil { + if err := writePlatformSSHKey(fileName, key.Key, userName); err != nil { log.Warnf("Error writing platform git ssh key: %v", err) - continue + // remove any stale key file so we don't leave old credentials behind + if removeErr := os.Remove( + fileName, + ); removeErr != nil && + !errors.Is(removeErr, os.ErrNotExist) { + log.Warnf("Error removing stale platform git ssh key: %v", removeErr) + } } + } +} - err = copy2.Chown(fileName, userName) - // do not exit on error, we can have non-fatal errors - if err != nil { - log.Warnf("Error chowning platform git ssh keys: %v", err) - } +func writePlatformSSHKey(fileName, encodedKey, userName string) error { + decoded, err := base64.StdEncoding.DecodeString(encodedKey) + if err != nil { + return err + } + if err := os.WriteFile(fileName, decoded, 0o600); err != nil { + return err } + // do not exit on error, we can have non-fatal errors + if err := copy2.Chown(fileName, userName); err != nil { + log.Warnf("Error chowning platform git ssh keys: %v", err) + } return nil } diff --git a/pkg/devcontainer/single.go b/pkg/devcontainer/single.go index 84a449f05..eb41dd084 100644 --- a/pkg/devcontainer/single.go +++ b/pkg/devcontainer/single.go @@ -276,12 +276,7 @@ func (r *runner) resolveNewContainer( r.injectDaemonEntrypoint(p, mergedConfig) - runOptions, err := r.buildRunOptionsForDelivery(mergedConfig, p.substitutionContext, buildInfo) - if err == nil { - if preStartErr := r.deliverPreStart(ctx, runOptions); preStartErr != nil { - log.Debugf("pre-start delivery skipped or failed, will use post-start: %v", preStartErr) - } - } + r.attemptPreStartDelivery(ctx, mergedConfig, p, buildInfo) if seedErr := r.seedWorkspaceVolume(ctx, p); seedErr != nil { return nil, fmt.Errorf("seed workspace volume: %w", seedErr) @@ -308,6 +303,23 @@ func (r *runner) resolveNewContainer( }, nil } +// attemptPreStartDelivery builds run options and attempts pre-start agent +// delivery. Failures are non-fatal: the caller falls back to post-start. +func (r *runner) attemptPreStartDelivery( + ctx context.Context, + mergedConfig *config.MergedDevContainerConfig, + p *resolveParams, + buildInfo *config.BuildInfo, +) { + runOptions, err := r.buildRunOptionsForDelivery(mergedConfig, p.substitutionContext, buildInfo) + if err != nil { + return + } + if preStartErr := r.deliverPreStart(ctx, runOptions); preStartErr != nil { + log.Debugf("pre-start delivery skipped or failed, will use post-start: %v", preStartErr) + } +} + func (r *runner) lingerWarning(ctx context.Context) string { helperProvider, ok := r.driver.(driver.DockerHelperProvider) if !ok { @@ -817,14 +829,7 @@ func (r *runner) addExtraEnvVars(env map[string]string) map[string]string { env[DevsyExtraEnvVar] = stringTrue env[RemoteContainersExtraEnvVar] = stringTrue - if r.workspaceConfig != nil && r.workspaceConfig.Workspace != nil && - r.workspaceConfig.Workspace.ID != "" { - env[pkgconfig.EnvWorkspaceID] = r.workspaceConfig.Workspace.ID - } - if r.workspaceConfig != nil && r.workspaceConfig.Workspace != nil && - r.workspaceConfig.Workspace.UID != "" { - env[pkgconfig.EnvWorkspaceUID] = r.workspaceConfig.Workspace.UID - } + r.addWorkspaceEnvVars(env) if os.Getenv(pkgconfig.EnvDisableTelemetry) == pkgconfig.BoolTrue { env[pkgconfig.EnvDisableTelemetry] = pkgconfig.BoolTrue @@ -835,6 +840,18 @@ func (r *runner) addExtraEnvVars(env map[string]string) map[string]string { return env } +func (r *runner) addWorkspaceEnvVars(env map[string]string) { + if r.workspaceConfig == nil || r.workspaceConfig.Workspace == nil { + return + } + if r.workspaceConfig.Workspace.ID != "" { + env[pkgconfig.EnvWorkspaceID] = r.workspaceConfig.Workspace.ID + } + if r.workspaceConfig.Workspace.UID != "" { + env[pkgconfig.EnvWorkspaceUID] = r.workspaceConfig.Workspace.UID + } +} + func GetStartScript(mergedConfig *config.MergedDevContainerConfig) string { customEntrypoints := mergedConfig.Entrypoints return `echo Container started diff --git a/pkg/dockercredentials/dockercredentials.go b/pkg/dockercredentials/dockercredentials.go index a2ee4f93a..e2670282c 100644 --- a/pkg/dockercredentials/dockercredentials.go +++ b/pkg/dockercredentials/dockercredentials.go @@ -90,14 +90,43 @@ func configureCredentials( return err } - // write credentials helper + if err := writeCredentialHelper(targetDir, binaryPath, shebang, port); err != nil { + return err + } + + dockerConfig.CredentialsStore = pkgconfig.BinaryName + err = dockerConfig.Save() + if err != nil { + return err + } + + err = file.Chown(userName, dockerConfig.Filename) + if err != nil { + return err + } + + return nil +} + +func writeCredentialHelper(targetDir, binaryPath, shebang string, port int) error { helperName := pkgconfig.DockerCredentialHelperName if runtime.GOOS == windowsOS { helperName += ".cmd" } credentialHelperPath := filepath.Join(targetDir, helperName) - var helperContent []byte + helperContent := buildHelperContent(binaryPath, shebang, port) + + // #nosec G306 -- executable file needs 0755 permissions + if err := os.WriteFile(credentialHelperPath, helperContent, 0o755); err != nil { + return fmt.Errorf("write credential helper: %w", err) + } + log.Debugf("Wrote docker credentials helper to %s", credentialHelperPath) + + return nil +} + +func buildHelperContent(binaryPath, shebang string, port int) []byte { if runtime.GOOS == windowsOS { escapedPath := strings.ReplaceAll(binaryPath, "%", "%%") script := fmt.Sprintf( @@ -105,38 +134,18 @@ func configureCredentials( escapedPath, port, ) - helperContent = []byte(script) - } else { - cmd := shellescape.QuoteCommand([]string{ - binaryPath, - "internal", - "agent", - "docker-credentials", - "--port", - fmt.Sprintf("%d", port), - }) - helperContent = []byte(shebang + "\n" + cmd + ` "$@"` + "\n") - } - - log.Debugf("Wrote docker credentials helper to %s", credentialHelperPath) - // #nosec G306 -- executable file needs 0755 permissions - err = os.WriteFile(credentialHelperPath, helperContent, 0o755) - if err != nil { - return fmt.Errorf("write credential helper: %w", err) + return []byte(script) } - dockerConfig.CredentialsStore = pkgconfig.BinaryName - err = dockerConfig.Save() - if err != nil { - return err - } - - err = file.Chown(userName, dockerConfig.Filename) - if err != nil { - return err - } - - return nil + cmd := shellescape.QuoteCommand([]string{ + binaryPath, + "internal", + "agent", + "docker-credentials", + "--port", + fmt.Sprintf("%d", port), + }) + return []byte(shebang + "\n" + cmd + ` "$@"` + "\n") } func ConfigureCredentialsDockerless(targetFolder string, port int) (string, error) { @@ -237,21 +246,9 @@ func GetAuthConfig(host string) (*Credentials, error) { return nil, err } - // let's try to query the containers ecosystem - // if the credentials the docker SDK returns are empty - // Unfortunately docker swallows credentials.errCredentialsNotFound - // so we only have the option to compare against an empty types.AuthConfig - empty := types.AuthConfig{} - if ac == empty { - sanitizedHost := strings.TrimPrefix(strings.TrimPrefix(host, "https://"), "http://") - dac, err := dockerconfig.GetCredentials(nil, sanitizedHost) - if err != nil { - return nil, err - } - ac.Username = dac.Username - ac.Password = dac.Password - ac.IdentityToken = dac.IdentityToken - ac.ServerAddress = host // Best approximation we have to mimic the docker type. + ac, err = fillFromContainerCredentials(ac, host) + if err != nil { + return nil, err } // In case of Azure registry we need to set the azure username to a default, in case it's not set. @@ -259,17 +256,40 @@ func GetAuthConfig(host string) (*Credentials, error) { ac.Username = AzureContainerRegistryUsername } + return credentialsFromAuthConfig(ac, host), nil +} + +// fillFromContainerCredentials queries the containers ecosystem when the docker +// SDK returns empty credentials. Docker swallows credentials.errCredentialsNotFound, +// so an empty types.AuthConfig is the only signal we have. +func fillFromContainerCredentials(ac types.AuthConfig, host string) (types.AuthConfig, error) { + empty := types.AuthConfig{} + if ac != empty { + return ac, nil + } + + sanitizedHost := strings.TrimPrefix(strings.TrimPrefix(host, "https://"), "http://") + dac, err := dockerconfig.GetCredentials(nil, sanitizedHost) + if err != nil { + return ac, err + } + ac.Username = dac.Username + ac.Password = dac.Password + ac.IdentityToken = dac.IdentityToken + ac.ServerAddress = host // Best approximation we have to mimic the docker type. + + return ac, nil +} + +func credentialsFromAuthConfig(ac types.AuthConfig, host string) *Credentials { + secret := ac.Password if ac.IdentityToken != "" { - return &Credentials{ - ServerURL: host, - Username: ac.Username, - Secret: ac.IdentityToken, - }, nil + secret = ac.IdentityToken } return &Credentials{ ServerURL: host, Username: ac.Username, - Secret: ac.Password, - }, nil + Secret: secret, + } } diff --git a/pkg/dockerfile/parse.go b/pkg/dockerfile/parse.go index f70e0cc09..298a69ffb 100644 --- a/pkg/dockerfile/parse.go +++ b/pkg/dockerfile/parse.go @@ -259,29 +259,7 @@ func EnsureFinalStageName(dockerfileContent, defaultLastStageName string) (strin return "", "", err } - var lastChild *parser.Node - stages := make(map[string]string) // stage name -> base image - - for _, child := range result.AST.Children { - if strings.ToLower(child.Value) == command.From { - lastChild = child - if child.Next != nil { - image := child.Next.Value - // Check if this FROM has an AS clause - if child.Next.Next != nil && child.Next.Next.Next != nil && - strings.EqualFold(child.Next.Next.Value, "as") { - stageName := child.Next.Next.Next.Value - // If image is a stage reference, resolve it - if baseImage, exists := stages[image]; exists { - stages[stageName] = baseImage - } else { - stages[stageName] = image - } - } - } - } - } - + lastChild := lastFromNode(result.AST.Children) if lastChild == nil { return "", "", fmt.Errorf("no FROM statement in dockerfile") } @@ -289,8 +267,7 @@ func EnsureFinalStageName(dockerfileContent, defaultLastStageName string) (strin return "", "", fmt.Errorf("cannot parse FROM statement in dockerfile") } - if lastChild.Next.Next != nil && lastChild.Next.Next.Next != nil && - strings.EqualFold(lastChild.Next.Next.Value, "as") { + if hasStageAlias(lastChild) { return lastChild.Next.Next.Next.Value, "", nil } @@ -301,6 +278,21 @@ func EnsureFinalStageName(dockerfileContent, defaultLastStageName string) (strin return defaultLastStageName, ReplaceInDockerfile(dockerfileContent, lastChild), nil } +func lastFromNode(children []*parser.Node) *parser.Node { + var lastChild *parser.Node + for _, child := range children { + if strings.ToLower(child.Value) == command.From { + lastChild = child + } + } + return lastChild +} + +func hasStageAlias(node *parser.Node) bool { + return node.Next.Next != nil && node.Next.Next.Next != nil && + strings.EqualFold(node.Next.Next.Value, "as") +} + func ReplaceInDockerfile(dockerfileContent string, node *parser.Node) string { scan := scanner.NewScanner(strings.NewReader(dockerfileContent)) var lines []string @@ -368,63 +360,86 @@ func Parse(dockerfileContent string) (*Dockerfile, error) { StagesByTarget: make(map[string]*Stage), } - directiveParser := parser.DirectiveParser{} - if directives, _ := directiveParser.ParseAll([]byte(dockerfileContent)); len(directives) > 0 { - d.Directives = directives - for _, directive := range directives { - if strings.EqualFold(directive.Name, "syntax") { - d.Syntax = directive.Value - break - } - } - } + d.parseDirectives(dockerfileContent) // Parse instructions with single loop isPreamble := true for _, instruction := range result.AST.Children { - cmd := strings.ToLower(instruction.Value) + isPreamble = d.appendInstruction(instruction, isPreamble) + } - if isPreamble && cmd == command.From { - isPreamble = false - d.Stages = append(d.Stages, parseStage(instruction)) - continue - } + d.indexStagesByTarget() - if isPreamble { - d.Preamble.Instructions = append(d.Preamble.Instructions, instruction) - switch cmd { - case command.Env: - d.Preamble.Envs = append(d.Preamble.Envs, parseEnv(instruction)...) - case command.Arg: - d.Preamble.Args = append(d.Preamble.Args, parseArg(instruction)) - } - continue - } + return d, nil +} - if cmd == command.From { - d.Stages = append(d.Stages, parseStage(instruction)) - continue - } +func (d *Dockerfile) parseDirectives(dockerfileContent string) { + directiveParser := parser.DirectiveParser{} + directives, _ := directiveParser.ParseAll([]byte(dockerfileContent)) + if len(directives) == 0 { + return + } - lastStage := d.Stages[len(d.Stages)-1] - lastStage.Instructions = append(lastStage.Instructions, instruction) - switch cmd { - case command.Env: - lastStage.Envs = append(lastStage.Envs, parseEnv(instruction)...) - case command.Arg: - lastStage.Args = append(lastStage.Args, parseArg(instruction)) - case command.User: - lastStage.Users = append(lastStage.Users, parseUser(instruction)) + d.Directives = directives + for _, directive := range directives { + if strings.EqualFold(directive.Name, "syntax") { + d.Syntax = directive.Value + break } } +} + +func (d *Dockerfile) appendInstruction(instruction *parser.Node, isPreamble bool) bool { + cmd := strings.ToLower(instruction.Value) + + if isPreamble && cmd == command.From { + d.Stages = append(d.Stages, parseStage(instruction)) + return false + } + + if isPreamble { + d.appendPreambleInstruction(instruction, cmd) + return true + } + + if cmd == command.From { + d.Stages = append(d.Stages, parseStage(instruction)) + return false + } + d.appendStageInstruction(instruction, cmd) + return false +} + +func (d *Dockerfile) appendPreambleInstruction(instruction *parser.Node, cmd string) { + d.Preamble.Instructions = append(d.Preamble.Instructions, instruction) + switch cmd { + case command.Env: + d.Preamble.Envs = append(d.Preamble.Envs, parseEnv(instruction)...) + case command.Arg: + d.Preamble.Args = append(d.Preamble.Args, parseArg(instruction)) + } +} + +func (d *Dockerfile) appendStageInstruction(instruction *parser.Node, cmd string) { + lastStage := d.Stages[len(d.Stages)-1] + lastStage.Instructions = append(lastStage.Instructions, instruction) + switch cmd { + case command.Env: + lastStage.Envs = append(lastStage.Envs, parseEnv(instruction)...) + case command.Arg: + lastStage.Args = append(lastStage.Args, parseArg(instruction)) + case command.User: + lastStage.Users = append(lastStage.Users, parseUser(instruction)) + } +} + +func (d *Dockerfile) indexStagesByTarget() { for _, stage := range d.Stages { if stage.Target != "" { d.StagesByTarget[stage.Target] = stage } } - - return d, nil } func parseUser(instruction *parser.Node) instructions.KeyValuePair { diff --git a/pkg/driver/kubernetes/driver.go b/pkg/driver/kubernetes/driver.go index aede8daca..7b1ab2310 100644 --- a/pkg/driver/kubernetes/driver.go +++ b/pkg/driver/kubernetes/driver.go @@ -127,49 +127,15 @@ func (k *KubernetesDriver) DeleteDevContainer(ctx context.Context, workspaceId s return err } - // delete pvc - log.Infof("Delete persistent volume claim %q", workspaceId) - err = k.client.Client(). - CoreV1(). - PersistentVolumeClaims(k.namespace). - Delete(ctx, workspaceId, metav1.DeleteOptions{ - GracePeriodSeconds: &[]int64{5}[0], - }) - if err != nil && !kerrors.IsNotFound(err) { - return fmt.Errorf("delete pvc: %w", err) - } - - // delete role binding & service account - if k.options.ClusterRole != "" { - log.Infof("Delete role binding %q", workspaceId) - err = k.client.Client(). - RbacV1(). - RoleBindings(k.namespace). - Delete(ctx, workspaceId, metav1.DeleteOptions{}) - if err != nil && !kerrors.IsNotFound(err) { - return fmt.Errorf("delete role binding: %w", err) - } - } - - // delete daemon config secret - if k.secretExists(ctx, getDaemonSecretName(workspaceId)) { - log.Infof("Delete daemon config secret %q", workspaceId) - err := k.DeleteSecret(ctx, getDaemonSecretName(workspaceId)) - if err != nil { - return err - } + if err := k.deletePersistentVolumeClaim(ctx, workspaceId); err != nil { + return err } - // delete pull secret - if k.options.KubernetesPullSecretsEnabled != "" { - log.Infof("Delete pull secret %q", workspaceId) - err := k.DeleteSecret(ctx, getPullSecretsName(workspaceId)) - if err != nil { - return err - } + if err := k.deleteRoleBinding(ctx, workspaceId); err != nil { + return err } - return nil + return k.deleteWorkspaceSecrets(ctx, workspaceId) } func (k *KubernetesDriver) CommandDevContainer( @@ -236,3 +202,58 @@ func (k *KubernetesDriver) GetDevContainerLogs( return nil } + +func (k *KubernetesDriver) deletePersistentVolumeClaim( + ctx context.Context, + workspaceId string, +) error { + log.Infof("Delete persistent volume claim %q", workspaceId) + err := k.client.Client(). + CoreV1(). + PersistentVolumeClaims(k.namespace). + Delete(ctx, workspaceId, metav1.DeleteOptions{ + GracePeriodSeconds: &[]int64{5}[0], + }) + if err != nil && !kerrors.IsNotFound(err) { + return fmt.Errorf("delete pvc: %w", err) + } + + return nil +} + +func (k *KubernetesDriver) deleteRoleBinding(ctx context.Context, workspaceId string) error { + if k.options.ClusterRole == "" { + return nil + } + + log.Infof("Delete role binding %q", workspaceId) + err := k.client.Client(). + RbacV1(). + RoleBindings(k.namespace). + Delete(ctx, workspaceId, metav1.DeleteOptions{}) + if err != nil && !kerrors.IsNotFound(err) { + return fmt.Errorf("delete role binding: %w", err) + } + + return nil +} + +func (k *KubernetesDriver) deleteWorkspaceSecrets(ctx context.Context, workspaceId string) error { + // delete daemon config secret + if k.secretExists(ctx, getDaemonSecretName(workspaceId)) { + log.Infof("Delete daemon config secret %q", workspaceId) + if err := k.DeleteSecret(ctx, getDaemonSecretName(workspaceId)); err != nil { + return err + } + } + + // delete pull secret + if k.options.KubernetesPullSecretsEnabled != "" { + log.Infof("Delete pull secret %q", workspaceId) + if err := k.DeleteSecret(ctx, getPullSecretsName(workspaceId)); err != nil { + return err + } + } + + return nil +} diff --git a/pkg/driver/kubernetes/init_container.go b/pkg/driver/kubernetes/init_container.go index 4ac6edb73..80bb821cf 100644 --- a/pkg/driver/kubernetes/init_container.go +++ b/pkg/driver/kubernetes/init_container.go @@ -15,20 +15,65 @@ func (k *KubernetesDriver) getInitContainers( initialize bool, ) ([]corev1.Container, error) { if !initialize { - retContainers := []corev1.Container{} // don't build init container and clean up existing one if defined - for _, container := range pod.Spec.InitContainers { - if container.Name == InitContainerName { - continue - } - retContainers = append(retContainers, container) - } + return filterOutInitContainer(pod.Spec.InitContainers), nil + } + + volumeMounts, commands := buildVolumeCopyCommands(options) + + retContainers, existingInitContainer := splitInitContainers(pod.Spec.InitContainers) + // check if there is at least one mount + if len(volumeMounts) == 0 { return retContainers, nil } + securityContext := &corev1.SecurityContext{ + RunAsUser: &[]int64{0}[0], + RunAsGroup: &[]int64{0}[0], + RunAsNonRoot: &[]bool{false}[0], + } + if k.options.StrictSecurity == pkgconfig.BoolTrue { + securityContext = nil + } + + resources := corev1.ResourceRequirements{} + if existingInitContainer != nil { + resources = existingInitContainer.Resources + } + + initContainer := corev1.Container{ + Name: InitContainerName, + Image: options.Image, + Command: []string{"sh"}, + Args: []string{"-c", strings.Join(commands, "\n") + "\n"}, + Resources: resources, + VolumeMounts: volumeMounts, + SecurityContext: securityContext, + } + + mergeContainer(&initContainer, existingInitContainer) + + retContainers = append(retContainers, initContainer) + return retContainers, nil +} + +func filterOutInitContainer(containers []corev1.Container) []corev1.Container { + retContainers := []corev1.Container{} + for _, container := range containers { + if container.Name == InitContainerName { + continue + } + retContainers = append(retContainers, container) + } + + return retContainers +} + +func buildVolumeCopyCommands( + options *driver.RunOptions, +) ([]corev1.VolumeMount, []string) { commands := []string{} - // find the volume type mounts volumeMounts := []corev1.VolumeMount{} for idx, mount := range options.Mounts { if mount.Type != "volume" { @@ -49,61 +94,37 @@ func (k *KubernetesDriver) getInitContainers( ) } - retContainers := []corev1.Container{} + return volumeMounts, commands +} - // merge with existing init container if it exists +func splitInitContainers(containers []corev1.Container) ([]corev1.Container, *corev1.Container) { + retContainers := []corev1.Container{} var existingInitContainer *corev1.Container - for i, container := range pod.Spec.InitContainers { + for i, container := range containers { if container.Name == InitContainerName { - existingInitContainer = &pod.Spec.InitContainers[i] + existingInitContainer = &containers[i] } else { retContainers = append(retContainers, container) } } - // check if there is at least one mount - if len(volumeMounts) == 0 { - return retContainers, nil - } - - securityContext := &corev1.SecurityContext{ - RunAsUser: &[]int64{0}[0], - RunAsGroup: &[]int64{0}[0], - RunAsNonRoot: &[]bool{false}[0], - } - if k.options.StrictSecurity == pkgconfig.BoolTrue { - securityContext = nil - } + return retContainers, existingInitContainer +} - resources := corev1.ResourceRequirements{} - if existingInitContainer != nil { - resources = existingInitContainer.Resources +func mergeContainer(dst, src *corev1.Container) { + if src == nil { + return } - initContainer := corev1.Container{ - Name: InitContainerName, - Image: options.Image, - Command: []string{"sh"}, - Args: []string{"-c", strings.Join(commands, "\n") + "\n"}, - Resources: resources, - VolumeMounts: volumeMounts, - SecurityContext: securityContext, - } + dst.Env = append(src.Env, dst.Env...) + dst.EnvFrom = src.EnvFrom + dst.Ports = src.Ports + dst.VolumeMounts = append( + src.VolumeMounts, + dst.VolumeMounts...) + dst.ImagePullPolicy = src.ImagePullPolicy - if existingInitContainer != nil { - initContainer.Env = append(existingInitContainer.Env, initContainer.Env...) - initContainer.EnvFrom = existingInitContainer.EnvFrom - initContainer.Ports = existingInitContainer.Ports - initContainer.VolumeMounts = append( - existingInitContainer.VolumeMounts, - initContainer.VolumeMounts...) - initContainer.ImagePullPolicy = existingInitContainer.ImagePullPolicy - - if initContainer.SecurityContext == nil && existingInitContainer.SecurityContext != nil { - initContainer.SecurityContext = existingInitContainer.SecurityContext - } + if dst.SecurityContext == nil && src.SecurityContext != nil { + dst.SecurityContext = src.SecurityContext } - - retContainers = append(retContainers, initContainer) - return retContainers, nil } diff --git a/pkg/driver/kubernetes/pullsecrets.go b/pkg/driver/kubernetes/pullsecrets.go index 9c1e27178..1c7b321c3 100644 --- a/pkg/driver/kubernetes/pullsecrets.go +++ b/pkg/driver/kubernetes/pullsecrets.go @@ -27,26 +27,17 @@ func (k *KubernetesDriver) EnsurePullSecret( if err != nil { return false, fmt.Errorf("retrieve credentials for %s: %w", host, err) } - if dockerCredentials == nil || dockerCredentials.Username == "" || - dockerCredentials.Secret == "" { + if !hasUsableCredentials(dockerCredentials) { log.Debugf("no credentials configured for registry %s; pulling anonymously", host) return false, nil } - if k.secretExists(ctx, pullSecretName) { - if !k.shouldRecreateSecret(ctx, dockerCredentials, pullSecretName, host) { - log.Debugf("Pull secret %q already exists and is up to date", pullSecretName) - return true, nil - } - - log.Debugf( - "Pull secret %q already exists, but is outdated. Recreating", - pullSecretName, - ) - err := k.DeleteSecret(ctx, pullSecretName) - if err != nil { - return false, err - } + needCreate, err := k.prepareSecretRecreation(ctx, dockerCredentials, pullSecretName, host) + if err != nil { + return false, err + } + if !needCreate { + return true, nil } err = k.createPullSecret(ctx, pullSecretName, dockerCredentials) @@ -58,6 +49,37 @@ func (k *KubernetesDriver) EnsurePullSecret( return true, nil } +func hasUsableCredentials(creds *dockercredentials.Credentials) bool { + return creds != nil && creds.Username != "" && creds.Secret != "" +} + +// prepareSecretRecreation deletes an existing pull secret if it is outdated and +// reports whether the secret still needs to be created. +func (k *KubernetesDriver) prepareSecretRecreation( + ctx context.Context, + dockerCredentials *dockercredentials.Credentials, + pullSecretName, host string, +) (bool, error) { + if !k.secretExists(ctx, pullSecretName) { + return true, nil + } + + if !k.shouldRecreateSecret(ctx, dockerCredentials, pullSecretName, host) { + log.Debugf("Pull secret %q already exists and is up to date", pullSecretName) + return false, nil + } + + log.Debugf( + "Pull secret %q already exists, but is outdated. Recreating", + pullSecretName, + ) + if err := k.DeleteSecret(ctx, pullSecretName); err != nil { + return false, err + } + + return true, nil +} + func (k *KubernetesDriver) ReadSecretContents( ctx context.Context, pullSecretName string, diff --git a/pkg/driver/kubernetes/pvc.go b/pkg/driver/kubernetes/pvc.go index c81de09f9..a7e7568bb 100644 --- a/pkg/driver/kubernetes/pvc.go +++ b/pkg/driver/kubernetes/pvc.go @@ -55,33 +55,13 @@ func (k *KubernetesDriver) buildPersistentVolumeClaim( if k.options.StorageClass != "" { storageClassName = &k.options.StorageClass } - accessMode := []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce} - if k.options.PvcAccessMode != "" { - switch k.options.PvcAccessMode { - case "RWO": - accessMode = []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce} - case "ROX": - accessMode = []corev1.PersistentVolumeAccessMode{corev1.ReadOnlyMany} - case "RWX": - accessMode = []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany} - case "RWOP": - accessMode = []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOncePod} - default: - accessMode = []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce} - } - } + accessMode := resolveAccessMode(k.options.PvcAccessMode) labels := map[string]string{} maps.Copy(labels, ExtraDevsyLabels) maps.Copy(labels, pkgconfig.K8sVolumeLabels(options.UID, pkgconfig.VolumeRoleWorkspace)) - annotations := map[string]string{} - annotations[DevsyInfoAnnotation] = containerInfo - extraAnnotations, err := parseLabels(k.options.PvcAnnotations) - if err != nil { - log.Errorf("Failed to parse annotations from PVC_ANNOTATIONS option: %v", err) - } - maps.Copy(annotations, extraAnnotations) + annotations := k.buildPvcAnnotations(containerInfo) return &corev1.PersistentVolumeClaim{ TypeMeta: metav1.TypeMeta{ @@ -105,6 +85,31 @@ func (k *KubernetesDriver) buildPersistentVolumeClaim( }, nil } +func resolveAccessMode(mode string) []corev1.PersistentVolumeAccessMode { + switch mode { + case "ROX": + return []corev1.PersistentVolumeAccessMode{corev1.ReadOnlyMany} + case "RWX": + return []corev1.PersistentVolumeAccessMode{corev1.ReadWriteMany} + case "RWOP": + return []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOncePod} + default: + return []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce} + } +} + +func (k *KubernetesDriver) buildPvcAnnotations(containerInfo string) map[string]string { + annotations := map[string]string{} + annotations[DevsyInfoAnnotation] = containerInfo + extraAnnotations, err := parseLabels(k.options.PvcAnnotations) + if err != nil { + log.Errorf("Failed to parse annotations from PVC_ANNOTATIONS option: %v", err) + } + maps.Copy(annotations, extraAnnotations) + + return annotations +} + func (k *KubernetesDriver) getDevContainerInformation( id string, options *driver.RunOptions, diff --git a/pkg/driver/kubernetes/run.go b/pkg/driver/kubernetes/run.go index f7ad8d076..1c11b474a 100644 --- a/pkg/driver/kubernetes/run.go +++ b/pkg/driver/kubernetes/run.go @@ -48,31 +48,46 @@ func (k *KubernetesDriver) RunDevContainer( log.Debugf("Running devcontainer for workspace %q", workspaceId) workspaceId = getID(workspaceId) - // namespace - if k.namespace != "" && k.options.CreateNamespace == pkgconfig.BoolTrue { - err := k.createNamespace(ctx) - if err != nil { - return err - } + if err := k.ensureNamespace(ctx); err != nil { + return err + } + + initialize, options, err := k.ensureDevContainerPvc(ctx, workspaceId, options) + if err != nil { + return err } - // check if persistent volume claim already exists + return k.runContainer(ctx, workspaceId, options, initialize) +} + +func (k *KubernetesDriver) ensureNamespace(ctx context.Context) error { + if k.namespace == "" || k.options.CreateNamespace != pkgconfig.BoolTrue { + return nil + } + + return k.createNamespace(ctx) +} + +func (k *KubernetesDriver) ensureDevContainerPvc( + ctx context.Context, + workspaceId string, + options *driver.RunOptions, +) (bool, *driver.RunOptions, error) { initialize := false pvc, containerInfo, err := k.getDevContainerPvc(ctx, workspaceId) if err != nil { - return err - } else if pvc == nil { + return false, nil, err + } + if pvc == nil { if options == nil { - return fmt.Errorf( + return false, nil, fmt.Errorf( "no options provided and no persistent volume claim found for workspace %q", workspaceId, ) } - // create persistent volume claim - err = k.createPersistentVolumeClaim(ctx, workspaceId, options) - if err != nil { - return err + if err := k.createPersistentVolumeClaim(ctx, workspaceId, options); err != nil { + return false, nil, err } initialize = true @@ -83,71 +98,188 @@ func (k *KubernetesDriver) RunDevContainer( options = containerInfo.Options } - // create dev container - err = k.runContainer(ctx, workspaceId, options, initialize) + return initialize, options, nil +} + +func (k *KubernetesDriver) runContainer( + ctx context.Context, + id string, + options *driver.RunOptions, + initialize bool, +) error { + if options == nil { + return fmt.Errorf("no run options provided for workspace %q", id) + } + + pod, err := k.buildPod(ctx, id, options, initialize) if err != nil { return err } - return nil + skip, err := k.reconcileExistingPod(ctx, id) + if err != nil { + return err + } + if skip { + return nil + } + + return k.runPod(ctx, id, pod) } -func (k *KubernetesDriver) runContainer( +func (k *KubernetesDriver) buildPod( ctx context.Context, id string, options *driver.RunOptions, initialize bool, -) (err error) { - // get workspace mount +) (*corev1.Pod, error) { + mount, err := k.resolveWorkspaceMount(options) + if err != nil { + return nil, err + } + + pod, err := loadPodTemplate(k.options.PodManifestTemplate) + if err != nil { + return nil, err + } + + initContainers, err := k.getInitContainers(options, pod, initialize) + if err != nil { + return nil, fmt.Errorf("build init container: %w", err) + } + + volumeMounts, tmpfsVolumes := buildVolumeMounts(mount, options) + capabilities := buildCapabilities(options.CapAdd) + envVars, daemonConfig := splitEnvVars(options.Env) + + serviceAccount, err := k.ensureServiceAccount(ctx, id) + if err != nil { + return nil, err + } + + meta, err := k.buildPodMetadata(pod, options) + if err != nil { + return nil, err + } + + daemonConfigSecretName, err := k.ensureDaemonConfig(ctx, id, daemonConfig) + if err != nil { + return nil, err + } + + pullSecretsCreated, err := k.ensurePullSecrets(ctx, id, options.Image) + if err != nil { + return nil, err + } + + k.assemblePodSpec(pod, id, &podSpecInputs{ + options: options, + meta: meta, + initContainers: initContainers, + volumeMounts: volumeMounts, + tmpfsVolumes: tmpfsVolumes, + capabilities: capabilities, + envVars: envVars, + serviceAccount: serviceAccount, + daemonConfigSecretName: daemonConfigSecretName, + pullSecretsCreated: pullSecretsCreated, + }) + + return pod, nil +} + +type podSpecInputs struct { + options *driver.RunOptions + meta *podMetadata + initContainers []corev1.Container + volumeMounts []corev1.VolumeMount + tmpfsVolumes []corev1.Volume + capabilities *corev1.Capabilities + envVars []corev1.EnvVar + serviceAccount string + daemonConfigSecretName string + pullSecretsCreated bool +} + +func (k *KubernetesDriver) assemblePodSpec(pod *corev1.Pod, id string, in *podSpecInputs) { + pod.Name = id + pod.Labels = in.meta.labels + + pod.Spec.ServiceAccountName = in.serviceAccount + pod.Spec.NodeSelector = in.meta.nodeSelector + pod.Spec.InitContainers = in.initContainers + pod.Spec.Containers = getContainers( + pod, + in.options.Image, + in.options.Entrypoint, + in.options.Cmd, + in.envVars, + in.volumeMounts, + in.capabilities, + in.meta.resources, + in.options.Privileged, + k.options.StrictSecurity, + in.daemonConfigSecretName, + ) + pod.Spec.Volumes = append( + getVolumes(pod, id, in.daemonConfigSecretName), + in.tmpfsVolumes...) + k.finalizePodSpec(pod, id, in.pullSecretsCreated) +} + +func (k *KubernetesDriver) resolveWorkspaceMount( + options *driver.RunOptions, +) (*config.Mount, error) { mount := options.WorkspaceMount if mount == nil { - return fmt.Errorf( + return nil, fmt.Errorf( "workspace mount is suppressed; cannot run in Kubernetes without a workspace mount", ) } if mount.Target == "" { - return fmt.Errorf("workspace mount target is empty") - } - if k.options.WorkspaceVolumeMount != "" { - // Ensure workspace volume mount option is parent or same dir as workspace mount - rel, err := filepath.Rel(k.options.WorkspaceVolumeMount, mount.Target) - switch { - case err != nil: - log.Warnf("Relative filepath: %v", err) - case strings.HasPrefix(rel, ".."): - log.Warnf( - "Workspace volume mount needs to be the same as the workspace mount or a parent, skipping option. "+ - "WorkspaceVolumeMount: %s, MountTarget: %s", - k.options.WorkspaceVolumeMount, - mount.Target, - ) - default: - mount.Target = k.options.WorkspaceVolumeMount - log.Debugf("Using workspace volume mount: %s", k.options.WorkspaceVolumeMount) - } + return nil, fmt.Errorf("workspace mount target is empty") + } + if k.options.WorkspaceVolumeMount == "" { + return mount, nil + } + + // Ensure workspace volume mount option is parent or same dir as workspace mount + rel, err := filepath.Rel(k.options.WorkspaceVolumeMount, mount.Target) + switch { + case err != nil: + log.Warnf("Relative filepath: %v", err) + case strings.HasPrefix(rel, ".."): + log.Warnf( + "Workspace volume mount needs to be the same as the workspace mount or a parent, skipping option. "+ + "WorkspaceVolumeMount: %s, MountTarget: %s", + k.options.WorkspaceVolumeMount, + mount.Target, + ) + default: + mount.Target = k.options.WorkspaceVolumeMount + log.Debugf("Using workspace volume mount: %s", k.options.WorkspaceVolumeMount) + } + + return mount, nil +} + +func loadPodTemplate(templatePath string) (*corev1.Pod, error) { + if len(templatePath) > 0 { + log.Debugf("trying to get pod template manifest from %s", templatePath) + return getPodTemplate(templatePath) } - // read pod template - pod := &corev1.Pod{ + return &corev1.Pod{ Spec: corev1.PodSpec{ RestartPolicy: corev1.RestartPolicyNever, }, - } - if len(k.options.PodManifestTemplate) > 0 { - log.Debugf("trying to get pod template manifest from %s", k.options.PodManifestTemplate) - pod, err = getPodTemplate(k.options.PodManifestTemplate) - if err != nil { - return err - } - } - - // get init containers - initContainers, err := k.getInitContainers(options, pod, initialize) - if err != nil { - return fmt.Errorf("build init container: %w", err) - } + }, nil +} - // loop over volume mounts +func buildVolumeMounts( + mount *config.Mount, + options *driver.RunOptions, +) ([]corev1.VolumeMount, []corev1.Volume) { volumeMounts := []corev1.VolumeMount{getVolumeMount(0, mount)} var tmpfsVolumes []corev1.Volume for idx, mount := range options.Mounts { @@ -177,20 +309,28 @@ func (k *KubernetesDriver) runContainer( } } - // capabilities - var capabilities *corev1.Capabilities - if len(options.CapAdd) > 0 { - capabilities = &corev1.Capabilities{} - for _, cap := range options.CapAdd { - capabilities.Add = append(capabilities.Add, corev1.Capability(cap)) - } + return volumeMounts, tmpfsVolumes +} + +func buildCapabilities(capAdd []string) *corev1.Capabilities { + if len(capAdd) == 0 { + return nil } - // env vars + capabilities := &corev1.Capabilities{} + for _, c := range capAdd { + capabilities.Add = append(capabilities.Add, corev1.Capability(c)) + } + + return capabilities +} + +// splitEnvVars converts the env map to EnvVars, extracting the daemon config +// value which is mounted through a secret instead. +func splitEnvVars(env map[string]string) ([]corev1.EnvVar, string) { envVars := []corev1.EnvVar{} daemonConfig := "" - for k, v := range options.Env { - // filter out daemon config, that's going to be mounted through a secret + for k, v := range env { if k == pkgconfig.EnvWorkspaceDaemonConfig { daemonConfig = v continue @@ -201,32 +341,46 @@ func (k *KubernetesDriver) runContainer( }) } - // service account - serviceAccount := "" - if k.options.ServiceAccount != "" { - serviceAccount = k.options.ServiceAccount + return envVars, daemonConfig +} - // create service account - err = k.createServiceAccount(ctx, id, serviceAccount) - if err != nil { - return fmt.Errorf("create service account: %w", err) - } +func (k *KubernetesDriver) ensureServiceAccount( + ctx context.Context, + id string, +) (string, error) { + if k.options.ServiceAccount == "" { + return "", nil + } + + serviceAccount := k.options.ServiceAccount + if err := k.createServiceAccount(ctx, id, serviceAccount); err != nil { + return "", fmt.Errorf("create service account: %w", err) } - // labels + return serviceAccount, nil +} + +type podMetadata struct { + labels map[string]string + nodeSelector map[string]string + resources corev1.ResourceRequirements +} + +func (k *KubernetesDriver) buildPodMetadata( + pod *corev1.Pod, + options *driver.RunOptions, +) (*podMetadata, error) { labels, err := getLabels(pod, k.options.Labels) if err != nil { - return err + return nil, err } labels[DevsyWorkspaceUIDLabel] = options.UID - // node selector nodeSelector, err := getNodeSelector(pod, k.options.NodeSelector) if err != nil { - return err + return nil, err } - // parse resources resources := corev1.ResourceRequirements{} if len(pod.Spec.Containers) > 0 { resources = pod.Spec.Containers[0].Resources @@ -235,47 +389,42 @@ func (k *KubernetesDriver) runContainer( resources = parseResources(k.options.Resources) } - // ensure daemon config secret - daemonConfigSecretName := "" - if daemonConfig != "" { - daemonConfigSecretName = getDaemonSecretName(id) - err = k.EnsureDaemonConfigSecret(ctx, daemonConfigSecretName, daemonConfig) - if err != nil { - return err - } + return &podMetadata{ + labels: labels, + nodeSelector: nodeSelector, + resources: resources, + }, nil +} + +func (k *KubernetesDriver) ensureDaemonConfig( + ctx context.Context, + id, daemonConfig string, +) (string, error) { + if daemonConfig == "" { + return "", nil } - // ensure pull secrets - pullSecretsCreated := false - if k.options.KubernetesPullSecretsEnabled == pkgconfig.BoolTrue && - k.agentConfig.InjectDockerCredentials == pkgconfig.BoolTrue { - pullSecretsCreated, err = k.EnsurePullSecret(ctx, getPullSecretsName(id), options.Image) - if err != nil { - return err - } + daemonConfigSecretName := getDaemonSecretName(id) + if err := k.EnsureDaemonConfigSecret(ctx, daemonConfigSecretName, daemonConfig); err != nil { + return "", err } - // create the pod manifest - pod.Name = id - pod.Labels = labels + return daemonConfigSecretName, nil +} - pod.Spec.ServiceAccountName = serviceAccount - pod.Spec.NodeSelector = nodeSelector - pod.Spec.InitContainers = initContainers - pod.Spec.Containers = getContainers( - pod, - options.Image, - options.Entrypoint, - options.Cmd, - envVars, - volumeMounts, - capabilities, - resources, - options.Privileged, - k.options.StrictSecurity, - daemonConfigSecretName, - ) - pod.Spec.Volumes = append(getVolumes(pod, id, daemonConfigSecretName), tmpfsVolumes...) +func (k *KubernetesDriver) ensurePullSecrets( + ctx context.Context, + id, image string, +) (bool, error) { + if k.options.KubernetesPullSecretsEnabled != pkgconfig.BoolTrue || + k.agentConfig.InjectDockerCredentials != pkgconfig.BoolTrue { + return false, nil + } + + return k.EnsurePullSecret(ctx, getPullSecretsName(id), image) +} + +func (k *KubernetesDriver) finalizePodSpec(pod *corev1.Pod, id string, pullSecretsCreated bool) { // avoids a problem where attaching volumes with large repositories would cause an extremely long pod startup time // because changing the ownership of all files takes longer than the kubelet expects it to if pod.Spec.SecurityContext == nil { @@ -287,45 +436,40 @@ func (k *KubernetesDriver) runContainer( pod.Spec.ImagePullSecrets = []corev1.LocalObjectReference{{Name: getPullSecretsName(id)}} } pod.Spec.RestartPolicy = corev1.RestartPolicyNever - // try to get existing pod +} + +func (k *KubernetesDriver) reconcileExistingPod(ctx context.Context, id string) (bool, error) { existingPod, err := k.getPod(ctx, id) if err != nil { - return fmt.Errorf("get pod: %s: %w", id, err) + return false, fmt.Errorf("get pod: %s: %w", id, err) + } + if existingPod == nil { + return false, nil } - if existingPod != nil { - existingOptions := &provider2.ProviderKubernetesDriverConfig{} - err := json.Unmarshal( - []byte(existingPod.GetAnnotations()[DevsyLastAppliedAnnotation]), - existingOptions, - ) - if err != nil { + existingOptions := &provider2.ProviderKubernetesDriverConfig{} + if raw := existingPod.GetAnnotations()[DevsyLastAppliedAnnotation]; raw != "" { + if err = json.Unmarshal([]byte(raw), existingOptions); err != nil { log.Errorf("Error unmarshalling existing provider options, continuing...: %s", err) } + } - // Nothing changed, can safely return - if optionsEqual(existingOptions, k.options) { - log.Infof( - "Pod %q already exists and nothing changed, skipping update", - existingPod.Name, - ) - return nil - } - - // Stop the current pod - log.Debug("Provider options changed") - err = k.waitPodDeleted(ctx, id) - if err != nil { - return fmt.Errorf("stop devcontainer: %s: %w", id, err) - } + // Nothing changed, can safely return + if optionsEqual(existingOptions, k.options) { + log.Infof( + "Pod %q already exists and nothing changed, skipping update", + existingPod.Name, + ) + return true, nil } - err = k.runPod(ctx, id, pod) - if err != nil { - return err + // Stop the current pod + log.Debug("Provider options changed") + if err := k.waitPodDeleted(ctx, id); err != nil { + return false, fmt.Errorf("stop devcontainer: %s: %w", id, err) } - return nil + return false, nil } func (k *KubernetesDriver) runPod(ctx context.Context, id string, pod *corev1.Pod) error { @@ -421,20 +565,7 @@ func getContainers( } } - if existingDevsyContainer != nil { - devsyContainer.Env = append(existingDevsyContainer.Env, devsyContainer.Env...) - devsyContainer.EnvFrom = existingDevsyContainer.EnvFrom - devsyContainer.Ports = existingDevsyContainer.Ports - devsyContainer.VolumeMounts = append( - existingDevsyContainer.VolumeMounts, - devsyContainer.VolumeMounts...) - devsyContainer.ImagePullPolicy = existingDevsyContainer.ImagePullPolicy - - if devsyContainer.SecurityContext == nil && - existingDevsyContainer.SecurityContext != nil { - devsyContainer.SecurityContext = existingDevsyContainer.SecurityContext - } - } + mergeContainer(&devsyContainer, existingDevsyContainer) retContainers = append(retContainers, devsyContainer) return retContainers diff --git a/pkg/driver/kubernetes/serviceaccount.go b/pkg/driver/kubernetes/serviceaccount.go index 30233d00b..4c928f18f 100644 --- a/pkg/driver/kubernetes/serviceaccount.go +++ b/pkg/driver/kubernetes/serviceaccount.go @@ -15,65 +15,89 @@ func (k *KubernetesDriver) createServiceAccount( ctx context.Context, id, serviceAccount string, ) error { - // try to find pvc + if err := k.ensureServiceAccountResource(ctx, serviceAccount); err != nil { + return err + } + + return k.ensureRoleBinding(ctx, id, serviceAccount) +} + +func (k *KubernetesDriver) ensureServiceAccountResource( + ctx context.Context, + serviceAccount string, +) error { _, err := k.client.Client(). CoreV1(). ServiceAccounts(k.namespace). Get(ctx, serviceAccount, metav1.GetOptions{}) if err != nil && !kerrors.IsNotFound(err) { return fmt.Errorf("get service account: %w", err) - } else if kerrors.IsNotFound(err) { - // create service account if it does not exist - log.Infof("Create Service Account %q", serviceAccount) - _, err := k.client.Client(). - CoreV1(). - ServiceAccounts(k.namespace). - Create(ctx, &corev1.ServiceAccount{ - ObjectMeta: metav1.ObjectMeta{ - Name: serviceAccount, - Labels: ExtraDevsyLabels, - }, - }, metav1.CreateOptions{}) - if err != nil && !kerrors.IsAlreadyExists(err) { - return fmt.Errorf("create service account: %w", err) - } + } + if !kerrors.IsNotFound(err) { + return nil + } + + // create service account if it does not exist + log.Infof("Create Service Account %q", serviceAccount) + _, err = k.client.Client(). + CoreV1(). + ServiceAccounts(k.namespace). + Create(ctx, &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: serviceAccount, + Labels: ExtraDevsyLabels, + }, + }, metav1.CreateOptions{}) + if err != nil && !kerrors.IsAlreadyExists(err) { + return fmt.Errorf("create service account: %w", err) + } + + return nil +} + +func (k *KubernetesDriver) ensureRoleBinding( + ctx context.Context, + id, serviceAccount string, +) error { + if k.options.ClusterRole == "" { + return nil } - // try to find role binding - if k.options.ClusterRole != "" { - _, err := k.client.Client(). - RbacV1(). - RoleBindings(k.namespace). - Get(ctx, id, metav1.GetOptions{}) - if err != nil && !kerrors.IsNotFound(err) { - return fmt.Errorf("get role binding: %w", err) - } else if kerrors.IsNotFound(err) { - // create role binding - log.Infof("Create Role Binding %q", serviceAccount) - _, err := k.client.Client(). - RbacV1(). - RoleBindings(k.namespace). - Create(ctx, &rbacv1.RoleBinding{ - ObjectMeta: metav1.ObjectMeta{ - Name: id, - Labels: ExtraDevsyLabels, - }, - Subjects: []rbacv1.Subject{ - { - Kind: "ServiceAccount", - Name: serviceAccount, - }, - }, - RoleRef: rbacv1.RoleRef{ - APIGroup: rbacv1.SchemeGroupVersion.Group, - Kind: "ClusterRole", - Name: k.options.ClusterRole, - }, - }, metav1.CreateOptions{}) - if err != nil && !kerrors.IsAlreadyExists(err) { - return fmt.Errorf("create role binding: %w", err) - } - } + _, err := k.client.Client(). + RbacV1(). + RoleBindings(k.namespace). + Get(ctx, id, metav1.GetOptions{}) + if err != nil && !kerrors.IsNotFound(err) { + return fmt.Errorf("get role binding: %w", err) + } + if !kerrors.IsNotFound(err) { + return nil + } + + // create role binding + log.Infof("Create Role Binding %q", serviceAccount) + _, err = k.client.Client(). + RbacV1(). + RoleBindings(k.namespace). + Create(ctx, &rbacv1.RoleBinding{ + ObjectMeta: metav1.ObjectMeta{ + Name: id, + Labels: ExtraDevsyLabels, + }, + Subjects: []rbacv1.Subject{ + { + Kind: "ServiceAccount", + Name: serviceAccount, + }, + }, + RoleRef: rbacv1.RoleRef{ + APIGroup: rbacv1.SchemeGroupVersion.Group, + Kind: "ClusterRole", + Name: k.options.ClusterRole, + }, + }, metav1.CreateOptions{}) + if err != nil && !kerrors.IsAlreadyExists(err) { + return fmt.Errorf("create role binding: %w", err) } return nil diff --git a/pkg/driver/kubernetes/wait.go b/pkg/driver/kubernetes/wait.go index 402317a4c..eae366c09 100644 --- a/pkg/driver/kubernetes/wait.go +++ b/pkg/driver/kubernetes/wait.go @@ -39,179 +39,272 @@ func (k *KubernetesDriver) waitPodRunning(ctx context.Context, id string) (*core return true, nil } - // check pod for problems - if pod.DeletionTimestamp != nil { - throttledLogger.Infof("Waiting, since pod %q is terminating", id) - return false, nil - } + return k.evaluatePodStatus(ctx, pod, &podStatusParams{ + id: id, + started: started, + throttledLogger: throttledLogger, + }) + }, + ) - // Let's print all conditions that are false to help people troubleshoot infra issues - var condMsg strings.Builder - if time.Since(started) > 45*time.Second { // start printing conditions after a delay - for _, cond := range pod.Status.Conditions { - if cond.Status == corev1.ConditionFalse { - fmt.Fprintf(&condMsg, "Condition %q is %s\n", cond.Type, cond.Status) - if cond.Reason != "" { - fmt.Fprintf(&condMsg, "%s Reason: %s\n", cond.Type, cond.Reason) - } - if cond.Message != "" { - fmt.Fprintf(&condMsg, "%s Message: %s\n", cond.Type, cond.Message) - } - } - } - } + return pod, err +} - // check pod status - if len(pod.Status.ContainerStatuses) < len(pod.Spec.Containers) { - msg := fmt.Sprintf("Waiting, since pod %q is starting", id) - if condMsg.String() != "" { - msg += fmt.Sprintf("\n%s", strings.TrimSpace(condMsg.String())) - } - throttledLogger.Infof("%s", msg) - return false, nil - } +type podStatusParams struct { + id string + started time.Time + throttledLogger *throttledlogger.ThrottledLogger +} - // check init container status - for _, c := range pod.Status.InitContainerStatuses { - containerStatus := &c - if IsWaiting(containerStatus) { - if IsCritical(containerStatus) { - return false, fmt.Errorf( - "pod %q init container %q is waiting to start: %s (%s)", - id, - c.Name, - c.State.Waiting.Message, - c.State.Waiting.Reason, - ) - } - if c.State.Waiting.Message == "" { - throttledLogger.Infof( - "Waiting, since pod %q init container %q is waiting to start: %s", - id, - c.Name, - c.State.Waiting.Reason, - ) - } else { - throttledLogger.Infof( - "Waiting, since pod %q init container %q is waiting to start: %s (%s)", - id, - c.Name, - c.State.Waiting.Message, - c.State.Waiting.Reason, - ) - } - - return false, nil - } - - if IsTerminated(containerStatus) && !Succeeded(containerStatus) { - return false, fmt.Errorf( - "pod %q init container %q is terminated: %s (%s)", - id, - c.Name, - c.State.Terminated.Message, - c.State.Terminated.Reason, - ) - } - - container, err := getContainer(pod.Spec.InitContainers, c.Name) - if err != nil { - throttledLogger.Infof("Could not find container %q", c.Name) - return false, err - } - - restartable := restartableInitContainer(container.RestartPolicy) - if restartable { - if !IsStarted(containerStatus) || !IsReady(containerStatus) { - throttledLogger.Infof( - "Waiting, since pod %q init container %q is not ready yet", - id, - c.Name, - ) - return false, nil - } - } else { - if IsRunning(containerStatus) { - throttledLogger.Infof( - "Waiting, since pod %q init container %q is running", - id, - c.Name, - ) - return false, nil - } - } - } +func (k *KubernetesDriver) evaluatePodStatus( + ctx context.Context, + pod *corev1.Pod, + params *podStatusParams, +) (bool, error) { + // check pod for problems + if pod.DeletionTimestamp != nil { + params.throttledLogger.Infof("Waiting, since pod %q is terminating", params.id) + return false, nil + } - // check container status - for _, c := range pod.Status.ContainerStatuses { - containerStatus := &c - // delete succeeded pods - if IsTerminated(containerStatus) && Succeeded(containerStatus) { - // delete pod that is succeeded - log.Debugf("Delete Pod %q because it is succeeded", id) - err = k.waitPodDeleted(ctx, id) - if err != nil { - return false, err - } - - return false, nil - } - - if IsWaiting(containerStatus) { - if IsCritical(containerStatus) { - return false, fmt.Errorf( - "pod %q container %q is waiting to start: %s (%s)", - id, - c.Name, - c.State.Waiting.Message, - c.State.Waiting.Reason, - ) - } - if c.State.Waiting.Message == "" { - throttledLogger.Infof( - "Waiting, since pod %q container %q is waiting to start: %s", - id, - c.Name, - c.State.Waiting.Reason, - ) - } else { - throttledLogger.Infof( - "Waiting, since pod %q container %q is waiting to start: %s (%s)", - id, - c.Name, - c.State.Waiting.Message, - c.State.Waiting.Reason, - ) - } - - return false, nil - } - - if IsTerminated(containerStatus) { - return false, fmt.Errorf( - "pod %q container %q is terminated: %s (%s)", - id, - c.Name, - c.State.Terminated.Message, - c.State.Terminated.Reason, - ) - } - - if !IsReady(containerStatus) { - throttledLogger.Infof( - "Waiting, since pod %q container %q is not ready yet", - id, - c.Name, - ) - return false, nil - } - } + // Let's print all conditions that are false to help people troubleshoot infra issues + condMsg := podConditionMessage(pod, params.started) - return true, nil - }, - ) + // check pod status + if len(pod.Status.ContainerStatuses) < len(pod.Spec.Containers) { + msg := fmt.Sprintf("Waiting, since pod %q is starting", params.id) + if condMsg != "" { + msg += fmt.Sprintf("\n%s", strings.TrimSpace(condMsg)) + } + params.throttledLogger.Infof("%s", msg) + return false, nil + } - return pod, err + ready, err := checkInitContainerStatuses(pod, params.id, params.throttledLogger) + if err != nil || !ready { + return false, err + } + + return k.checkContainerStatuses(ctx, pod, params.id, params.throttledLogger) +} + +func podConditionMessage(pod *corev1.Pod, started time.Time) string { + if time.Since(started) <= 45*time.Second { // start printing conditions after a delay + return "" + } + + var condMsg strings.Builder + for _, cond := range pod.Status.Conditions { + if cond.Status != corev1.ConditionFalse { + continue + } + fmt.Fprintf(&condMsg, "Condition %q is %s\n", cond.Type, cond.Status) + if cond.Reason != "" { + fmt.Fprintf(&condMsg, "%s Reason: %s\n", cond.Type, cond.Reason) + } + if cond.Message != "" { + fmt.Fprintf(&condMsg, "%s Message: %s\n", cond.Type, cond.Message) + } + } + + return condMsg.String() +} + +func checkInitContainerStatuses( + pod *corev1.Pod, + id string, + throttledLogger *throttledlogger.ThrottledLogger, +) (bool, error) { + for _, c := range pod.Status.InitContainerStatuses { + proceed, err := checkInitContainerStatus(pod, id, &c, throttledLogger) + if err != nil || !proceed { + return false, err + } + } + + return true, nil +} + +func checkInitContainerStatus( + pod *corev1.Pod, + id string, + c *corev1.ContainerStatus, + throttledLogger *throttledlogger.ThrottledLogger, +) (bool, error) { + if IsWaiting(c) { + return handleInitContainerWaiting(id, c, throttledLogger) + } + + if IsTerminated(c) && !Succeeded(c) { + return false, fmt.Errorf( + "pod %q init container %q is terminated: %s (%s)", + id, + c.Name, + c.State.Terminated.Message, + c.State.Terminated.Reason, + ) + } + + container, err := getContainer(pod.Spec.InitContainers, c.Name) + if err != nil { + throttledLogger.Infof("Could not find container %q", c.Name) + return false, err + } + + return initContainerReady(container, c, id, throttledLogger), nil +} + +func handleInitContainerWaiting( + id string, + c *corev1.ContainerStatus, + throttledLogger *throttledlogger.ThrottledLogger, +) (bool, error) { + if IsCritical(c) { + return false, fmt.Errorf( + "pod %q init container %q is waiting to start: %s (%s)", + id, + c.Name, + c.State.Waiting.Message, + c.State.Waiting.Reason, + ) + } + + logWaiting(throttledLogger, "init container", id, c) + return false, nil +} + +func initContainerReady( + container *corev1.Container, + c *corev1.ContainerStatus, + id string, + throttledLogger *throttledlogger.ThrottledLogger, +) bool { + if restartableInitContainer(container.RestartPolicy) { + if !IsStarted(c) || !IsReady(c) { + throttledLogger.Infof( + "Waiting, since pod %q init container %q is not ready yet", + id, + c.Name, + ) + return false + } + return true + } + + if IsRunning(c) { + throttledLogger.Infof( + "Waiting, since pod %q init container %q is running", + id, + c.Name, + ) + return false + } + + return true +} + +func (k *KubernetesDriver) checkContainerStatuses( + ctx context.Context, + pod *corev1.Pod, + id string, + throttledLogger *throttledlogger.ThrottledLogger, +) (bool, error) { + for _, c := range pod.Status.ContainerStatuses { + proceed, err := k.checkContainerStatus(ctx, id, &c, throttledLogger) + if err != nil || !proceed { + return false, err + } + } + + return true, nil +} + +func (k *KubernetesDriver) checkContainerStatus( + ctx context.Context, + id string, + c *corev1.ContainerStatus, + throttledLogger *throttledlogger.ThrottledLogger, +) (bool, error) { + // delete succeeded pods + if IsTerminated(c) && Succeeded(c) { + // delete pod that is succeeded + log.Debugf("Delete Pod %q because it is succeeded", id) + if err := k.waitPodDeleted(ctx, id); err != nil { + return false, err + } + + return false, nil + } + + if IsWaiting(c) { + return handleContainerWaiting(id, c, throttledLogger) + } + + if IsTerminated(c) { + return false, fmt.Errorf( + "pod %q container %q is terminated: %s (%s)", + id, + c.Name, + c.State.Terminated.Message, + c.State.Terminated.Reason, + ) + } + + if !IsReady(c) { + throttledLogger.Infof( + "Waiting, since pod %q container %q is not ready yet", + id, + c.Name, + ) + return false, nil + } + + return true, nil +} + +func handleContainerWaiting( + id string, + c *corev1.ContainerStatus, + throttledLogger *throttledlogger.ThrottledLogger, +) (bool, error) { + if IsCritical(c) { + return false, fmt.Errorf( + "pod %q container %q is waiting to start: %s (%s)", + id, + c.Name, + c.State.Waiting.Message, + c.State.Waiting.Reason, + ) + } + + logWaiting(throttledLogger, "container", id, c) + return false, nil +} + +func logWaiting( + throttledLogger *throttledlogger.ThrottledLogger, + kind, id string, + c *corev1.ContainerStatus, +) { + if c.State.Waiting.Message == "" { + throttledLogger.Infof( + "Waiting, since pod %q %s %q is waiting to start: %s", + id, + kind, + c.Name, + c.State.Waiting.Reason, + ) + return + } + + throttledLogger.Infof( + "Waiting, since pod %q %s %q is waiting to start: %s (%s)", + id, + kind, + c.Name, + c.State.Waiting.Message, + c.State.Waiting.Reason, + ) } func (k *KubernetesDriver) getPod(ctx context.Context, id string) (*corev1.Pod, error) { diff --git a/pkg/extract/compress.go b/pkg/extract/compress.go index 4e352b871..60e9db60f 100644 --- a/pkg/extract/compress.go +++ b/pkg/extract/compress.go @@ -190,8 +190,12 @@ func (a *Archiver) tarFile(target string, targetStat os.FileInfo) error { return nil } - // Case regular file - f, err := os.Open(filepath) + return a.writeRegularFileBody(target, filepath, targetStat) +} + +func (a *Archiver) writeRegularFileBody(target, filePath string, targetStat os.FileInfo) error { + // #nosec G304 -- path is derived from the archive being created, not external input + f, err := os.Open(filePath) if err != nil { // We ignore open file and just treat it as okay return nil diff --git a/pkg/extract/extract.go b/pkg/extract/extract.go index 11ac37bc1..9fbb539e6 100644 --- a/pkg/extract/extract.go +++ b/pkg/extract/extract.go @@ -37,23 +37,12 @@ func Extract(origReader io.Reader, destFolder string, options ...Option) error { // read ahead bufioReader := bufio.NewReaderSize(origReader, 1024*1024) - testBytes, err := bufioReader.Peek(2) // read 2 bytes + reader, closer, err := detectReader(bufioReader) if err != nil { return err } - - // is gzipped? - var reader io.Reader - if testBytes[0] == 31 && testBytes[1] == 139 { - gzipReader, err := gzip.NewReader(bufioReader) - if err != nil { - return fmt.Errorf("error decompressing: %w", err) - } - defer func() { _ = gzipReader.Close() }() - - reader = gzipReader - } else { - reader = bufioReader + if closer != nil { + defer func() { _ = closer.Close() }() } tarReader := tar.NewReader(reader) @@ -67,6 +56,23 @@ func Extract(origReader io.Reader, destFolder string, options ...Option) error { } } +func detectReader(bufioReader *bufio.Reader) (io.Reader, io.Closer, error) { + testBytes, err := bufioReader.Peek(2) // read 2 bytes + if err != nil { + return nil, nil, err + } + + if testBytes[0] == 31 && testBytes[1] == 139 { + gzipReader, err := gzip.NewReader(bufioReader) + if err != nil { + return nil, nil, fmt.Errorf("error decompressing: %w", err) + } + return gzipReader, gzipReader, nil + } + + return bufioReader, nil, nil +} + // withinDir checks that resolved stays inside the destFolder boundary. func withinDir(resolved, destFolder string) bool { cleanDest := filepath.Clean(destFolder) + string(os.PathSeparator) diff --git a/pkg/gitsshsigning/helper.go b/pkg/gitsshsigning/helper.go index 8183a41b3..ae57c0eb0 100644 --- a/pkg/gitsshsigning/helper.go +++ b/pkg/gitsshsigning/helper.go @@ -152,80 +152,92 @@ func removeGitConfigHelper(gitConfigPath, userName string) error { return nil } -func removeSignatureHelper(content string) string { - type sectionKind int - const ( - sectionNone sectionKind = iota - sectionGpgSSH - sectionGpg - sectionUser - ) +type sectionKind int - current := sectionNone - var buf []string - var out []string - - flush := func() { - switch current { - case sectionGpgSSH: - out = append(out, filterSection(buf, func(trimmed string) bool { - return strings.HasPrefix(trimmed, "program = "+pkgconfig.SSHSignatureHelperName) - })...) - case sectionGpg: - out = append(out, filterSection(buf, func(trimmed string) bool { - return strings.HasPrefix(trimmed, "format = ssh") - })...) - case sectionUser: - // Only strip [user] sections that contain nothing but signingkey - // entries — these are the ones appended by GitConfigTemplate. - // Sections with other entries (name, email, etc.) are user-owned - // and must be preserved intact to avoid data loss. - if isDevsyOnlyUserSection(buf) { - // Drop the entire section — it was appended by devsy. - } else { - out = append(out, buf...) - } - } - buf = nil - } +const ( + sectionNone sectionKind = iota + sectionGpgSSH + sectionGpg + sectionUser +) +const ( + sectionHeaderGpgSSH = `[gpg "ssh"]` + sectionHeaderGpg = "[gpg]" + sectionHeaderUser = "[user]" +) + +type gitConfigFilter struct { + current sectionKind + buf []string + out []string +} + +func removeSignatureHelper(content string) string { + f := &gitConfigFilter{current: sectionNone} for line := range strings.Lines(content) { - line = strings.TrimRight(line, "\n") - trimmed := strings.TrimSpace(line) + f.process(strings.TrimRight(line, "\r\n")) + } + f.flush() - if isSectionHeader(trimmed) { - if current != sectionNone { - flush() - } - switch trimmed { - case `[gpg "ssh"]`: - current = sectionGpgSSH - case "[gpg]": - current = sectionGpg - case "[user]": - current = sectionUser - default: - current = sectionNone - } - if current != sectionNone { - buf = append(buf, line) - continue - } - } + return strings.Join(f.out, "\n") +} - if current != sectionNone { - buf = append(buf, line) - continue +func (f *gitConfigFilter) process(line string) { + trimmed := strings.TrimSpace(line) + + if isSectionHeader(trimmed) { + if f.current != sectionNone { + f.flush() + } + f.current = sectionFor(trimmed) + if f.current != sectionNone { + f.buf = append(f.buf, line) + return } + } - out = append(out, line) + if f.current != sectionNone { + f.buf = append(f.buf, line) + return } - if current != sectionNone { - flush() + f.out = append(f.out, line) +} + +func (f *gitConfigFilter) flush() { + switch f.current { + case sectionGpgSSH: + f.out = append(f.out, filterSection(f.buf, func(trimmed string) bool { + return strings.HasPrefix(trimmed, "program = "+pkgconfig.SSHSignatureHelperName) + })...) + case sectionGpg: + f.out = append(f.out, filterSection(f.buf, func(trimmed string) bool { + return strings.HasPrefix(trimmed, "format = ssh") + })...) + case sectionUser: + // Only strip [user] sections that contain nothing but signingkey + // entries — these are the ones appended by GitConfigTemplate. + // Sections with other entries (name, email, etc.) are user-owned + // and must be preserved intact to avoid data loss. + if !isDevsyOnlyUserSection(f.buf) { + f.out = append(f.out, f.buf...) + } } + f.buf = nil +} - return strings.Join(out, "\n") +func sectionFor(trimmed string) sectionKind { + switch trimmed { + case sectionHeaderGpgSSH: + return sectionGpgSSH + case sectionHeaderGpg: + return sectionGpg + case sectionHeaderUser: + return sectionUser + default: + return sectionNone + } } func isSectionHeader(trimmed string) bool { diff --git a/pkg/ide/fleet/fleet.go b/pkg/ide/fleet/fleet.go index b19cc8067..30dc34709 100644 --- a/pkg/ide/fleet/fleet.go +++ b/pkg/ide/fleet/fleet.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "path/filepath" + "regexp" "runtime" "strings" @@ -28,6 +29,8 @@ const ( DownloadArm64Option = "DOWNLOAD_ARM64" ) +var fleetVersionRegex = regexp.MustCompile(`^[a-zA-Z0-9._-]+$`) + var Options = ide.Options{ VersionOption: { Name: VersionOption, @@ -109,36 +112,13 @@ func (o *FleetServer) Start(binaryPath, location, projectDir string) error { err := command.StartBackgroundOnce("fleet", func() (*exec.Cmd, error) { log.Infof("Starting fleet in background") - // Determine version of fleet to use - var runCommand string version := Options.GetValue(o.values, VersionOption) - if version == "latest" { - runCommand = fmt.Sprintf( - "%s launch workspace -- --projectDir %q --cache-path %q --auth=accept-everyone --publish --enableSmartMode", - binaryPath, - projectDir, - location, - ) - } else { - runCommand = fmt.Sprintf( - "%s launch workspace --workspace-version %s -- --projectDir %q --cache-path %q "+ - "--auth=accept-everyone --publish --enableSmartMode", - binaryPath, - version, - projectDir, - location, - ) - } - - args := []string{} - if o.userName != "" { - args = append(args, "su", o.userName, "-c", runCommand) - } else { - args = append(args, "sh", "-c", runCommand) + runCommand, err := fleetRunCommand(binaryPath, projectDir, location, version) + if err != nil { + return nil, err } - cmd := exec.Command(args[0], args[1:]...) + cmd := o.commandForShell(runCommand) cmd.Dir = location - var err error readCloser, err = cmd.StdoutPipe() if err != nil { return nil, err @@ -156,27 +136,74 @@ func (o *FleetServer) Start(binaryPath, location, projectDir string) error { // wait for the jet brains url and then exit log.Infof("waiting for fleet to start") + return o.waitForFleetURL(readCloser, stderrBuffer, location) +} + +func fleetRunCommand(binaryPath, projectDir, location, version string) (string, error) { + if version == "latest" { + return fmt.Sprintf( + "%s launch workspace -- --projectDir %q --cache-path %q --auth=accept-everyone --publish --enableSmartMode", + binaryPath, + projectDir, + location, + ), nil + } + if !fleetVersionRegex.MatchString(version) { + return "", fmt.Errorf( + "invalid fleet version %q: must match %s", + version, + fleetVersionRegex.String(), + ) + } + return fmt.Sprintf( + "%s launch workspace --workspace-version %q -- --projectDir %q --cache-path %q "+ + "--auth=accept-everyone --publish --enableSmartMode", + binaryPath, + version, + projectDir, + location, + ), nil +} + +func (o *FleetServer) commandForShell(runCommand string) *exec.Cmd { + var args []string + if o.userName != "" { + args = []string{"su", o.userName, "-c", runCommand} + } else { + args = []string{"sh", "-c", runCommand} + } + // #nosec G204 -- runCommand is assembled from a fixed template with validated/quoted + // arguments (version is regex-validated, paths are %q-quoted), so it cannot be exploited. + return exec.Command(args[0], args[1:]...) +} + +func (o *FleetServer) waitForFleetURL( + readCloser io.ReadCloser, + stderrBuffer *bytes.Buffer, + location string, +) error { s := scanner.NewScanner(readCloser) stdoutBuffer := &bytes.Buffer{} for s.Scan() { text := s.Text() - if strings.Contains(text, "https://fleet.jetbrains.com/") { - index := strings.Index(text, "https://fleet.jetbrains.com/") - fleetURLFile := filepath.Join(location, FleetURLFileName) - err = os.WriteFile( - fleetURLFile, - []byte(strings.TrimSpace(text[index:])), - 0o600, - ) // #nosec G703 - if err != nil { - return err - } - - log.Infof("fleet started") - return o.startMonitor() - } else { + if !strings.Contains(text, "https://fleet.jetbrains.com/") { _, _ = stdoutBuffer.Write([]byte(text + "\n")) + continue } + + index := strings.Index(text, "https://fleet.jetbrains.com/") + fleetURLFile := filepath.Join(location, FleetURLFileName) + err := os.WriteFile( + fleetURLFile, + []byte(strings.TrimSpace(text[index:])), + 0o600, + ) // #nosec G703 + if err != nil { + return err + } + + log.Infof("fleet started") + return o.startMonitor() } return fmt.Errorf( @@ -195,14 +222,7 @@ func (o *FleetServer) startMonitor() error { return command.StartBackgroundOnce("fleet-monitor", func() (*exec.Cmd, error) { log.Infof("starting fleet monitor in background") runCommand := fmt.Sprintf("%s internal fleet-server --workspaceid %s", self, "test") - args := []string{} - if o.userName != "" { - args = append(args, "su", o.userName, "-c", runCommand) - } else { - args = append(args, "sh", "-c", runCommand) - } - cmd := exec.Command(args[0], args[1:]...) - return cmd, nil + return o.commandForShell(runCommand), nil }) } diff --git a/pkg/ide/ideparse/parse.go b/pkg/ide/ideparse/parse.go index d932c8461..cedf2b69f 100644 --- a/pkg/ide/ideparse/parse.go +++ b/pkg/ide/ideparse/parse.go @@ -240,17 +240,7 @@ func RefreshIDEOptions( ide string, options []string, ) (*provider.Workspace, error) { - ide = strings.ToLower(ide) - if ide == "" { - switch { - case workspace.IDE.Name != "": - ide = workspace.IDE.Name - case devsyConfig.Current().DefaultIDE != "": - ide = devsyConfig.Current().DefaultIDE - default: - ide = detect() - } - } + ide = resolveIDEName(devsyConfig, workspace, strings.ToLower(ide)) // get ide options ideOptions, err := GetIDEOptions(ide) @@ -258,9 +248,55 @@ func RefreshIDEOptions( return nil, err } - // get global options and set them as non user - // provided. - retValues := devsyConfig.IDEOptions(ide) + retValues, err := buildIDEValues(buildIDEValuesParams{ + devsyConfig: devsyConfig, + workspace: workspace, + ide: ide, + options: options, + ideOptions: ideOptions, + }) + if err != nil { + return nil, err + } + + // check if we need to modify workspace + if workspace.IDE.Name != ide || !reflect.DeepEqual(workspace.IDE.Options, retValues) { + workspace.IDE.Name = ide + workspace.IDE.Options = retValues + err = provider.SaveWorkspaceConfig(workspace) + if err != nil { + return nil, fmt.Errorf("save workspace: %w", err) + } + } + + return workspace, nil +} + +func resolveIDEName(devsyConfig *config.Config, workspace *provider.Workspace, ide string) string { + if ide != "" { + return ide + } + switch { + case workspace.IDE.Name != "": + return workspace.IDE.Name + case devsyConfig.Current().DefaultIDE != "": + return devsyConfig.Current().DefaultIDE + default: + return detect() + } +} + +type buildIDEValuesParams struct { + devsyConfig *config.Config + workspace *provider.Workspace + ide string + options []string + ideOptions ide.Options +} + +func buildIDEValues(p buildIDEValuesParams) (map[string]config.OptionValue, error) { + // get global options and set them as non user provided. + retValues := p.devsyConfig.IDEOptions(p.ide) for k, v := range retValues { retValues[k] = config.OptionValue{ Value: v.Value, @@ -268,8 +304,8 @@ func RefreshIDEOptions( } // get existing options - if ide == workspace.IDE.Name { - for k, v := range workspace.IDE.Options { + if p.ide == p.workspace.IDE.Name { + for k, v := range p.workspace.IDE.Options { if !v.UserProvided { continue } @@ -279,23 +315,13 @@ func RefreshIDEOptions( } // get user options - values, err := ParseOptions(options, ideOptions) + values, err := ParseOptions(p.options, p.ideOptions) if err != nil { return nil, fmt.Errorf("parse options: %w", err) } maps.Copy(retValues, values) - // check if we need to modify workspace - if workspace.IDE.Name != ide || !reflect.DeepEqual(workspace.IDE.Options, retValues) { - workspace.IDE.Name = ide - workspace.IDE.Options = retValues - err = provider.SaveWorkspaceConfig(workspace) - if err != nil { - return nil, fmt.Errorf("save workspace: %w", err) - } - } - - return workspace, nil + return retValues, nil } func GetIDEOptions(ide string) (ide.Options, error) { @@ -346,36 +372,8 @@ func ParseOptions(options []string, ideOptions ide.Options) (map[string]config.O ) } - if ideOption.ValidationPattern != "" { - matcher, err := regexp.Compile(ideOption.ValidationPattern) - if err != nil { - return nil, err - } - - if !matcher.MatchString(value) { - if ideOption.ValidationMessage != "" { - return nil, fmt.Errorf("%s", ideOption.ValidationMessage) - } - - return nil, fmt.Errorf( - "invalid value %q for option %q, has to match the following regEx: %s", - value, - key, - ideOption.ValidationPattern, - ) - } - } - - if len(ideOption.Enum) > 0 { - found := slices.Contains(ideOption.Enum, value) - if !found { - return nil, fmt.Errorf( - "invalid value %q for option %q, has to match one of the following values: %v", - value, - key, - ideOption.Enum, - ) - } + if err := validateOptionValue(ideOption, key, value); err != nil { + return nil, err } retMap[key] = config.OptionValue{ @@ -387,6 +385,41 @@ func ParseOptions(options []string, ideOptions ide.Options) (map[string]config.O return retMap, nil } +func validateOptionValue(ideOption ide.Option, key, value string) error { + if ideOption.ValidationPattern != "" { + matcher, err := regexp.Compile(ideOption.ValidationPattern) + if err != nil { + return err + } + + if !matcher.MatchString(value) { + if ideOption.ValidationMessage != "" { + return fmt.Errorf("%s", ideOption.ValidationMessage) + } + + return fmt.Errorf( + "invalid value %q for option %q, has to match the following regEx: %s", + value, + key, + ideOption.ValidationPattern, + ) + } + } + + if len(ideOption.Enum) > 0 { + if !slices.Contains(ideOption.Enum, value) { + return fmt.Errorf( + "invalid value %q for option %q, has to match one of the following values: %v", + value, + key, + ideOption.Enum, + ) + } + } + + return nil +} + func detect() string { if command.Exists("code") { return string(config.IDEVSCode) diff --git a/pkg/ide/jetbrains/generic.go b/pkg/ide/jetbrains/generic.go index 3f84bffbe..5be88d926 100644 --- a/pkg/ide/jetbrains/generic.go +++ b/pkg/ide/jetbrains/generic.go @@ -148,6 +148,13 @@ func (o *GenericJetBrainsServer) Install(setupInfo *config.Result) error { } log.Infof("installed backend: displayName=%s, id=%s", o.options.DisplayName, o.options.ID) + return o.installPlugins(setupInfo, targetLocation, baseFolder) +} + +func (o *GenericJetBrainsServer) installPlugins( + setupInfo *config.Result, + targetLocation, baseFolder string, +) error { jetBrainsConfiguration := config.GetJetBrainsConfiguration(setupInfo.MergedConfig) plugins := jetBrainsConfiguration.Plugins @@ -175,7 +182,7 @@ func (o *GenericJetBrainsServer) Install(setupInfo *config.Result) error { // we are running as root, // so for the plugins to be installed for the correct user, // user the installPluginsCommand runs as needs to be adjusted: - err = command.ForUser(installPluginsCommand, o.userName) + err := command.ForUser(installPluginsCommand, o.userName) if err != nil { return err } diff --git a/pkg/ide/rstudio/rstudio.go b/pkg/ide/rstudio/rstudio.go index 4e4dee80c..cc24f4a20 100644 --- a/pkg/ide/rstudio/rstudio.go +++ b/pkg/ide/rstudio/rstudio.go @@ -85,6 +85,33 @@ func (o *RStudioServer) Install() error { } log.Info("Installing RStudio") + if err := o.installSteps(debPath); err != nil { + return err + } + log.Info("installed RStudio") + + return o.Start() +} + +func (o *RStudioServer) Start() error { + return command.StartBackgroundOnce("rstudio", func() (*exec.Cmd, error) { + log.Info("Starting RStudio") + runCommand := "rstudio-server start" + args := []string{} + if o.userName != "" { + args = append(args, "su", o.userName, "-w", "SSH_AUTH_SOCK", "-l", "-c", runCommand) + } else { + args = append(args, "sh", "-l", "-c", runCommand) + } + cmd := exec.Command( + args[0], + args[1:]...) // #nosec G204 -- args internally constructed, not user-tainted + cmd.Dir = o.workspaceFolder + return cmd, nil + }) +} + +func (o *RStudioServer) installSteps(debPath string) error { err := ensureGdebi() if err != nil { return err @@ -110,29 +137,7 @@ func (o *RStudioServer) Install() error { return err } - err = setupPreferences(o.workspaceFolder, o.userName) - if err != nil { - return err - } - log.Info("installed RStudio") - - return o.Start() -} - -func (o *RStudioServer) Start() error { - return command.StartBackgroundOnce("rstudio", func() (*exec.Cmd, error) { - log.Info("Starting RStudio") - runCommand := "rstudio-server start" - args := []string{} - if o.userName != "" { - args = append(args, "su", o.userName, "-w", "SSH_AUTH_SOCK", "-l", "-c", runCommand) - } else { - args = append(args, "sh", "-l", "-c", runCommand) - } - cmd := exec.Command(args[0], args[1:]...) - cmd.Dir = o.workspaceFolder - return cmd, nil - }) + return setupPreferences(o.workspaceFolder, o.userName) } func ensureGdebi() error { diff --git a/pkg/ide/vscode/vscode.go b/pkg/ide/vscode/vscode.go index c8437b35f..4bb7c972b 100644 --- a/pkg/ide/vscode/vscode.go +++ b/pkg/ide/vscode/vscode.go @@ -347,12 +347,13 @@ func isNumeric(s string) bool { // versions), it returns the one whose parent directory has the most recent // modification time, so that an auto-updated server is preferred over a // stale one. -func (o *VsCodeServer) findInDir(root, binName string) string { - type candidate struct { - path string - modTime time.Time - } - var candidates []candidate +type binaryCandidate struct { + path string + modTime time.Time +} + +func collectBinaryCandidates(root, binName string) []binaryCandidate { + var candidates []binaryCandidate _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { if err != nil { @@ -365,25 +366,35 @@ func (o *VsCodeServer) findInDir(root, binName string) string { return filepath.SkipDir } - // The VS Code server gets installed into a staging directory, which is - // later renamed. Do not consider the staging directory a valid - // destination, as it won't be valid by the time the server binary is - // called. - if d.IsDir() && strings.Contains(pathRelative, ".staging") { - return filepath.SkipDir + if d.IsDir() { + // The VS Code server gets installed into a staging directory, which + // is later renamed. Do not consider the staging directory a valid + // destination, as it won't be valid by the time the server binary + // is called. + if strings.Contains(pathRelative, ".staging") { + return filepath.SkipDir + } + return nil } - if !d.IsDir() && d.Name() == binName { - dirInfo, err := os.Stat(filepath.Dir(path)) - if err != nil { - return nil - } - candidates = append(candidates, candidate{path: path, modTime: dirInfo.ModTime()}) + if d.Name() != binName { + return nil } + dirInfo, err := os.Stat(filepath.Dir(path)) + if err != nil { + return nil + } + candidates = append(candidates, binaryCandidate{path: path, modTime: dirInfo.ModTime()}) + return nil }) + return candidates +} + +func (o *VsCodeServer) findInDir(root, binName string) string { + candidates := collectBinaryCandidates(root, binName) if len(candidates) == 0 { return "" } diff --git a/pkg/inject/inject.go b/pkg/inject/inject.go index 6e8cca205..d332f93f2 100644 --- a/pkg/inject/inject.go +++ b/pkg/inject/inject.go @@ -51,27 +51,15 @@ type InjectOptions struct { // Deprecated: Inject is part of the legacy shell injection path. Platform-native AgentDelivery // implementations (LocalDockerDelivery, RemoteDockerDelivery, KubernetesDelivery) are the replacements. func Inject(opts InjectOptions) (bool, error) { - if opts.Ctx == nil { - return false, fmt.Errorf("context is required") - } - if opts.Exec == nil { - return false, fmt.Errorf("exec function is required") - } - if opts.ScriptParams == nil { - return false, fmt.Errorf("script params is required") + if err := validateInjectOptions(opts); err != nil { + return false, err } start := time.Now() log.Infof("injection: start") defer func() { log.Infof("injection: complete elapsed=%s", time.Since(start)) }() - if opts.ScriptParams.PreferAgentDownload { - url := "" - if opts.ScriptParams.DownloadURLs != nil { - url = opts.ScriptParams.DownloadURLs.Base - } - log.Debugf("prefer downloading agent from URL: url=%s", url) - } + logPreferAgentDownload(opts.ScriptParams) scriptRawCode, err := GenerateScript(Script, opts.ScriptParams) if err != nil { @@ -109,18 +97,14 @@ func Inject(opts InjectOptions) (bool, error) { // start execution of inject.sh execErrChan := make(chan error, 1) - go func() { - defer func() { _ = stdoutWriter.Close() }() - defer log.Debug("done exec") - - err := opts.Exec(cancelCtx, scriptRawCode, stdinReader, stdoutWriter, delayedStderr) - if err != nil && !errors.Is(err, context.Canceled) && - !strings.Contains(err.Error(), "signal: ") { - execErrChan <- command.WrapCommandError(delayedStderr.Buffer(), err) - } else { - execErrChan <- nil - } - }() + go runInjectScript(cancelCtx, runInjectScriptParams{ + opts: opts, + script: scriptRawCode, + stdin: stdinReader, + stdout: stdoutWriter, + delayedStderr: delayedStderr, + execErrChan: execErrChan, + }) // inject file injectChan := make(chan injectResult, 1) @@ -143,15 +127,80 @@ func Inject(opts InjectOptions) (bool, error) { } }() + return awaitInjectResult(awaitInjectResultParams{ + opts: opts, + start: start, + execErrChan: execErrChan, + injectChan: injectChan, + delayedStderr: delayedStderr, + }) +} + +func validateInjectOptions(opts InjectOptions) error { + if opts.Ctx == nil { + return fmt.Errorf("context is required") + } + if opts.Exec == nil { + return fmt.Errorf("exec function is required") + } + if opts.ScriptParams == nil { + return fmt.Errorf("script params is required") + } + return nil +} + +func logPreferAgentDownload(params *Params) { + if !params.PreferAgentDownload { + return + } + url := "" + if params.DownloadURLs != nil { + url = params.DownloadURLs.Base + } + log.Debugf("prefer downloading agent from URL: url=%s", url) +} + +type runInjectScriptParams struct { + opts InjectOptions + script string + stdin io.Reader + stdout io.WriteCloser + delayedStderr *delayedWriter + execErrChan chan<- error +} + +func runInjectScript(ctx context.Context, p runInjectScriptParams) { + defer func() { _ = p.stdout.Close() }() + defer log.Debug("done exec") + + err := p.opts.Exec(ctx, p.script, p.stdin, p.stdout, p.delayedStderr) + if err != nil && !errors.Is(err, context.Canceled) && + !strings.Contains(err.Error(), "signal: ") { + p.execErrChan <- command.WrapCommandError(p.delayedStderr.Buffer(), err) + } else { + p.execErrChan <- nil + } +} + +type awaitInjectResultParams struct { + opts InjectOptions + start time.Time + execErrChan chan error + injectChan chan injectResult + delayedStderr *delayedWriter +} + +func awaitInjectResult(p awaitInjectResultParams) (bool, error) { // wait here var result injectResult + var err error select { - case err = <-execErrChan: - result = <-injectChan - case result = <-injectChan: + case err = <-p.execErrChan: + result = <-p.injectChan + case result = <-p.injectChan: // we don't wait for the command termination here and will just retry on error } - log.Debugf("injection: payload delivered elapsed=%s", time.Since(start)) + log.Debugf("injection: payload delivered elapsed=%s", time.Since(p.start)) if result.err != nil { return result.wasExecuted, result.err @@ -165,18 +214,18 @@ func Inject(opts InjectOptions) (bool, error) { return result.wasExecuted, err } - if result.wasExecuted || opts.ScriptParams.Command == "" { + if result.wasExecuted || p.opts.ScriptParams.Command == "" { return result.wasExecuted, nil } log.Debugf("Rerun command as binary was injected") - delayedStderr.Start() - return true, opts.Exec( - opts.Ctx, - opts.ScriptParams.Command, - opts.Stdin, - opts.Stdout, - delayedStderr, + p.delayedStderr.Start() + return true, p.opts.Exec( + p.opts.Ctx, + p.opts.ScriptParams.Command, + p.opts.Stdin, + p.opts.Stdout, + p.delayedStderr, ) } @@ -224,22 +273,55 @@ func inject( log.Debugf("inject binary") defer log.Debugf("done injecting binary") - fileReader, err := getFileReader(localFile, lineStr) - if err != nil { + if err := injectBinaryPayload(localFile, lineStr, stdin, stdout); err != nil { return false, err } - defer func() { _ = fileReader.Close() }() - err = injectBinary(fileReader, stdin, stdout) - if err != nil { - return false, err - } - _ = stdout.Close() // start exec with command return false, nil - } else if lineStr != "done" { + } + if lineStr != "done" { return false, fmt.Errorf("unexpected message during inject %s", lineStr) } + return pipeStreams(pipeStreamsParams{ + stdin: stdin, + stdinOut: stdinOut, + stdoutOut: stdoutOut, + stdout: stdout, + delayedStderr: delayedStderr, + }) +} + +func injectBinaryPayload( + localFile LocalFile, + lineStr string, + stdin io.WriteCloser, + stdout io.ReadCloser, +) error { + fileReader, err := getFileReader(localFile, lineStr) + if err != nil { + return err + } + defer func() { _ = fileReader.Close() }() + + if err := injectBinary(fileReader, stdin, stdout); err != nil { + return err + } + _ = stdout.Close() + return nil +} + +type pipeStreamsParams struct { + stdin io.WriteCloser + stdinOut io.Reader + stdoutOut io.Writer + stdout io.ReadCloser + delayedStderr *delayedWriter +} + +func pipeStreams(p pipeStreamsParams) (bool, error) { + stdoutOut := p.stdoutOut + stdinOut := p.stdinOut if stdoutOut == nil { stdoutOut = io.Discard } @@ -248,10 +330,10 @@ func inject( } // now pipe reader into stdout - delayedStderr.Start() + p.delayedStderr.Start() return true, pipe( - stdin, stdinOut, - stdoutOut, stdout, + p.stdin, stdinOut, + stdoutOut, p.stdout, ) } diff --git a/pkg/netstat/netstat_util.go b/pkg/netstat/netstat_util.go index 439550fba..a224db4d1 100644 --- a/pkg/netstat/netstat_util.go +++ b/pkg/netstat/netstat_util.go @@ -121,37 +121,10 @@ func parseSocktab(r io.Reader, accept AcceptFn) ([]SockTabEntry, error) { br.Scan() for br.Scan() { - var e SockTabEntry - line := br.Text() - // Skip comments - if i := strings.Index(line, "#"); i >= 0 { - line = line[:i] - } - fields := strings.Fields(line) - if len(fields) < 12 { - return nil, fmt.Errorf("netstat: not enough fields: %v, %v", len(fields), fields) - } - addr, err := parseAddr(fields[1]) - if err != nil { - return nil, err - } - e.LocalAddr = addr - addr, err = parseAddr(fields[2]) - if err != nil { - return nil, err - } - e.RemoteAddr = addr - u, err := strconv.ParseUint(fields[3], 16, 8) - if err != nil { - return nil, err - } - e.State = SkState(u) - u, err = strconv.ParseUint(fields[7], 10, 32) + e, err := parseSockTabLine(br.Text()) if err != nil { return nil, err } - e.UID = uint32(u) - e.ino = fields[9] if accept(&e) { tab = append(tab, e) } @@ -159,6 +132,45 @@ func parseSocktab(r io.Reader, accept AcceptFn) ([]SockTabEntry, error) { return tab, br.Err() } +func parseSockTabLine(line string) (SockTabEntry, error) { + var e SockTabEntry + // Skip comments + if i := strings.Index(line, "#"); i >= 0 { + line = line[:i] + } + fields := strings.Fields(line) + if len(fields) < 12 { + return e, fmt.Errorf("netstat: not enough fields: %v, %v", len(fields), fields) + } + + localAddr, err := parseAddr(fields[1]) + if err != nil { + return e, err + } + e.LocalAddr = localAddr + + remoteAddr, err := parseAddr(fields[2]) + if err != nil { + return e, err + } + e.RemoteAddr = remoteAddr + + state, err := strconv.ParseUint(fields[3], 16, 8) + if err != nil { + return e, err + } + e.State = SkState(state) + + uid, err := strconv.ParseUint(fields[7], 10, 32) + if err != nil { + return e, err + } + e.UID = uint32(uid) + e.ino = fields[9] + + return e, nil +} + type procFd struct { base string pid int @@ -199,29 +211,46 @@ func (p *procFd) iterFdDir() { continue } - for i := range p.sktab { - sk := &p.sktab[i] - ss := sockPrefix + sk.ino + "]" - if ss != lname { - continue - } - if p.p == nil { - stat, err := os.Open(path.Join(p.base, "stat")) - if err != nil { - return - } - n, err := stat.Read(buf[:]) - _ = stat.Close() - if err != nil { - return - } - z := bytes.SplitN(buf[:n], []byte(" "), 3) - name := getProcName(z[1]) - p.p = &Process{p.pid, name} - } - sk.Process = p.p + if err := p.assignSocketProcess(lname, buf[:]); err != nil { + return + } + } +} + +func (p *procFd) assignSocketProcess(lname string, buf []byte) error { + for i := range p.sktab { + sk := &p.sktab[i] + if sockPrefix+sk.ino+"]" != lname { + continue } + if err := p.ensureProcess(buf); err != nil { + return err + } + sk.Process = p.p + } + return nil +} + +func (p *procFd) ensureProcess(buf []byte) error { + if p.p != nil { + return nil + } + + stat, err := os.Open(path.Join(p.base, "stat")) + if err != nil { + return err + } + n, err := stat.Read(buf) + _ = stat.Close() + if err != nil { + return err + } + z := bytes.SplitN(buf[:n], []byte(" "), 3) + if len(z) < 2 { + return fmt.Errorf("unexpected /proc/%d/stat format", p.pid) } + p.p = &Process{p.pid, getProcName(z[1])} + return nil } func extractProcInfo(sktab []SockTabEntry) { diff --git a/pkg/netstat/watcher.go b/pkg/netstat/watcher.go index 552cc16ad..f9de20cd9 100644 --- a/pkg/netstat/watcher.go +++ b/pkg/netstat/watcher.go @@ -94,57 +94,80 @@ func (w *Watcher) runOnce() error { return err } - // stop ports that are not there anymore + if err := w.stopRemovedPorts(newPorts); err != nil { + return err + } + + if err := w.startNewPorts(newPorts); err != nil { + return err + } + + w.forwardedPorts = newPorts + return nil +} + +func (w *Watcher) stopRemovedPorts(newPorts map[string]bool) error { for port := range w.forwardedPorts { - if !newPorts[port] { - log.Debugf("Stop port %s", port) - err = w.forwarder.StopForward(port) - if err != nil { - return fmt.Errorf("error stop forwarding port %s: %w", port, err) - } + if newPorts[port] { + continue + } + log.Debugf("Stop port %s", port) + if err := w.forwarder.StopForward(port); err != nil { + return fmt.Errorf("error stop forwarding port %s: %w", port, err) } } + return nil +} - // start ports that were not there before +func (w *Watcher) startNewPorts(newPorts map[string]bool) error { for port := range newPorts { - if !w.forwardedPorts[port] { - if w.portFilter != nil && !w.portFilter(port) { - log.Debugf("Skipping port %s (filtered)", port) - continue - } - - attr := w.resolveAttr(port) - if attr.OnAutoForward == AutoForwardIgnore { - log.Debugf("Skipping port %s (onAutoForward=ignore)", port) - continue - } + if w.forwardedPorts[port] { + continue + } + if err := w.startPort(port); err != nil { + return err + } + } + return nil +} - switch attr.OnAutoForward { - case AutoForwardSilent, "": - if attr.Label != "" { - log.Debugf("Found open port %s (%s) ready to forward", port, attr.Label) - } else { - log.Debugf("Found open port %s ready to forward", port) - } - default: - if attr.Label != "" { - log.Infof("Found open port %s (%s) ready to forward", port, attr.Label) - } else { - log.Infof("Found open port %s ready to forward", port) - } - } +func (w *Watcher) startPort(port string) error { + if w.portFilter != nil && !w.portFilter(port) { + log.Debugf("Skipping port %s (filtered)", port) + return nil + } - err = w.forwarder.Forward(port, attr) - if err != nil { - return fmt.Errorf("error forwarding port %s: %w", port, err) - } - } + attr := w.resolveAttr(port) + if attr.OnAutoForward == AutoForwardIgnore { + log.Debugf("Skipping port %s (onAutoForward=ignore)", port) + return nil } - w.forwardedPorts = newPorts + logDiscoveredPort(port, attr) + + if err := w.forwarder.Forward(port, attr); err != nil { + return fmt.Errorf("error forwarding port %s: %w", port, err) + } return nil } +func logDiscoveredPort(port string, attr PortForwardAttribute) { + switch attr.OnAutoForward { + case AutoForwardSilent, "": + if attr.Label != "" { + log.Debugf("Found open port %s (%s) ready to forward", port, attr.Label) + } else { + log.Debugf("Found open port %s ready to forward", port) + } + default: + if attr.Label != "" { + log.Infof("Found open port %s (%s) ready to forward", port, attr.Label) + } else { + log.Infof("Found open port %s ready to forward", port) + } + } +} + func (w *Watcher) resolveAttr(port string) PortForwardAttribute { if w.attrResolver == nil { return PortForwardAttribute{} diff --git a/pkg/options/resolve.go b/pkg/options/resolve.go index c385812a0..80729caa3 100644 --- a/pkg/options/resolve.go +++ b/pkg/options/resolve.go @@ -68,19 +68,26 @@ func ResolveAndSaveOptionsMachine( // save machine config if machine != nil { - machine.Provider.Options = resolvedOptions - - if !reflect.DeepEqual(beforeConfigOptions, machine.Provider.Options) { - err = provider.SaveMachineConfig(machine) - if err != nil { - return machine, err - } + if err := saveMachineIfChanged(machine, beforeConfigOptions, resolvedOptions); err != nil { + return machine, err } } return machine, nil } +func saveMachineIfChanged( + machine *provider.Machine, + beforeConfigOptions, resolvedOptions map[string]config.OptionValue, +) error { + machine.Provider.Options = resolvedOptions + if reflect.DeepEqual(beforeConfigOptions, machine.Provider.Options) { + return nil + } + + return provider.SaveMachineConfig(machine) +} + func ResolveAndSaveOptionsWorkspace( ctx context.Context, devConfig *config.Config, @@ -407,35 +414,58 @@ func filterResolvedOptions( userOptions map[string]string, ) { for k := range resolvedOptions { - // check if user supplied - if userOptions != nil { - _, ok := userOptions[k] - if ok { - continue - } + if shouldRemoveResolvedOption(removeResolvedOptionParams{ + k: k, + beforeConfigOptions: beforeConfigOptions, + providerValues: providerValues, + providerOptions: providerOptions, + userOptions: userOptions, + }) { + delete(resolvedOptions, k) } + } +} - // check if it was there before - if beforeConfigOptions != nil { - _, ok := beforeConfigOptions[k] - if ok { - continue - } - } +type removeResolvedOptionParams struct { + k string + beforeConfigOptions map[string]config.OptionValue + providerValues map[string]config.OptionValue + providerOptions map[string]*types.Option + userOptions map[string]string +} - // check if not available in the provider values - if providerValues != nil { - _, ok := providerValues[k] - if !ok { - continue - } - } +func shouldRemoveResolvedOption(p removeResolvedOptionParams) bool { + if keptByUserOrBefore(p.k, p.userOptions, p.beforeConfigOptions) { + return false + } + + if _, ok := p.providerValues[p.k]; !ok { + return false + } - // check if not global - if providerOptions == nil || providerOptions[k] == nil || !providerOptions[k].Global { - continue + return isGlobalOption(p.providerOptions, p.k) +} + +func keptByUserOrBefore( + k string, + userOptions map[string]string, + beforeConfigOptions map[string]config.OptionValue, +) bool { + if userOptions != nil { + if _, ok := userOptions[k]; ok { + return true } + } - delete(resolvedOptions, k) + if beforeConfigOptions != nil { + if _, ok := beforeConfigOptions[k]; ok { + return true + } } + + return false +} + +func isGlobalOption(providerOptions map[string]*types.Option, k string) bool { + return providerOptions != nil && providerOptions[k] != nil && providerOptions[k].Global } diff --git a/pkg/options/resolve_test.go b/pkg/options/resolve_test.go index aabecdbad..9a4347e9b 100644 --- a/pkg/options/resolve_test.go +++ b/pkg/options/resolve_test.go @@ -656,50 +656,70 @@ func TestResolveOptions(t *testing.T) { }, } - for _, testCase := range testCases { - fmt.Println(testCase.Name) - resolverOpts := []resolver.Option{ - resolver.WithSkipRequired(testCase.SkipRequired), - resolver.WithResolveSubOptions(), - } - if !testCase.DontResolveLocal { - resolverOpts = append(resolverOpts, resolver.WithResolveLocal()) - } - if testCase.ResolveGlobal { - resolverOpts = append(resolverOpts, resolver.WithResolveGlobal()) - } - r := resolver.New(testCase.UserValues, testCase.ExtraValues, resolverOpts...) - options, dynamicOptions, err := r.Resolve( - context.Background(), - testCase.ResolvedDynamicDefinitions, - testCase.ProviderOptions, - testCase.ResolvedValues, - ) - if !testCase.ExpectErr { - assert.NilError(t, err, testCase.Name) - } else if testCase.ExpectErr { - if err == nil { - t.Fatalf("expected error, got nil error in test case %s", testCase.Name) - } + for _, tc := range testCases { + t.Run(tc.Name, func(t *testing.T) { + runResolveTestCase(t, tc) + }) + } +} - continue +func runResolveTestCase(t *testing.T, tc testCase) { + t.Helper() + r := resolver.New(tc.UserValues, tc.ExtraValues, buildResolverOpts(tc)...) + options, dynamicOptions, err := r.Resolve( + context.Background(), + tc.ResolvedDynamicDefinitions, + tc.ProviderOptions, + tc.ResolvedValues, + ) + if tc.ExpectErr { + if err == nil { + t.Fatalf("expected error, got nil error in test case %s", tc.Name) } - strOptions := map[string]string{} - for k, v := range options { - strOptions[k] = v.Value - } - if len(testCase.ExpectedOptions) > 0 { - assert.DeepEqual(t, strOptions, testCase.ExpectedOptions) - } else { - assert.DeepEqual(t, strOptions, map[string]string{}) - } + return + } - if len(testCase.ExpectedDynamicOptions) > 0 { - assert.DeepEqual(t, dynamicOptions, testCase.ExpectedDynamicOptions) - } else { - assert.DeepEqual(t, dynamicOptions, config.OptionDefinitions{}) - } + assert.NilError(t, err, tc.Name) + assertResolvedOptions(t, tc, options, dynamicOptions) +} + +func buildResolverOpts(tc testCase) []resolver.Option { + resolverOpts := []resolver.Option{ + resolver.WithSkipRequired(tc.SkipRequired), + resolver.WithResolveSubOptions(), + } + if !tc.DontResolveLocal { + resolverOpts = append(resolverOpts, resolver.WithResolveLocal()) + } + if tc.ResolveGlobal { + resolverOpts = append(resolverOpts, resolver.WithResolveGlobal()) + } + + return resolverOpts +} + +func assertResolvedOptions( + t *testing.T, + tc testCase, + options map[string]config.OptionValue, + dynamicOptions config.OptionDefinitions, +) { + t.Helper() + strOptions := map[string]string{} + for k, v := range options { + strOptions[k] = v.Value + } + if len(tc.ExpectedOptions) > 0 { + assert.DeepEqual(t, strOptions, tc.ExpectedOptions) + } else { + assert.DeepEqual(t, strOptions, map[string]string{}) + } + + if len(tc.ExpectedDynamicOptions) > 0 { + assert.DeepEqual(t, dynamicOptions, tc.ExpectedDynamicOptions) + } else { + assert.DeepEqual(t, dynamicOptions, config.OptionDefinitions{}) } } diff --git a/pkg/options/resolver/parse.go b/pkg/options/resolver/parse.go index b7ea2bbfd..83183eb08 100644 --- a/pkg/options/resolver/parse.go +++ b/pkg/options/resolver/parse.go @@ -32,73 +32,87 @@ func printUnusedUserValues( } func validateUserValue(optionName, userValue string, option *types.Option) error { - if option.ValidationPattern != "" { - matcher, err := regexp.Compile(option.ValidationPattern) - if err != nil { - return err + if err := validateUserValuePattern(optionName, userValue, option); err != nil { + return err + } + + if err := validateUserValueEnum(optionName, userValue, option); err != nil { + return err + } + + return validateUserValueType(optionName, userValue, option) +} + +func validateUserValuePattern(optionName, userValue string, option *types.Option) error { + if option.ValidationPattern == "" { + return nil + } + + matcher, err := regexp.Compile(option.ValidationPattern) + if err != nil { + return err + } + + if matcher.MatchString(userValue) { + return nil + } + + if option.ValidationMessage != "" { + return fmt.Errorf("%s", option.ValidationMessage) + } + + return fmt.Errorf( + "invalid value %q for option %q, has to match the following regEx: %s", + userValue, + optionName, + option.ValidationPattern, + ) +} + +func validateUserValueEnum(optionName, userValue string, option *types.Option) error { + if len(option.Enum) == 0 { + return nil + } + + for _, e := range option.Enum { + if userValue == e.Value { + return nil } + } - if !matcher.MatchString(userValue) { - if option.ValidationMessage != "" { - return fmt.Errorf("%s", option.ValidationMessage) - } + return fmt.Errorf( + "invalid value %q for option %q, has to match one of the following values: %v", + userValue, + optionName, + option.Enum, + ) +} +func validateUserValueType(optionName, userValue string, option *types.Option) error { + switch option.Type { + case "number": + if _, err := strconv.ParseInt(userValue, 10, 64); err != nil { return fmt.Errorf( - "invalid value %q for option %q, has to match the following regEx: %s", + "invalid value %q for option %q, must be a number", userValue, optionName, - option.ValidationPattern, ) } - } - - if len(option.Enum) > 0 { - found := false - for _, e := range option.Enum { - if userValue == e.Value { - found = true - break - } - } - if !found { + case "boolean": + if _, err := strconv.ParseBool(userValue); err != nil { return fmt.Errorf( - "invalid value %q for option %q, has to match one of the following values: %v", + "invalid value %q for option %q, must be a boolean", userValue, optionName, - option.Enum, ) } - } - - if option.Type != "" { - switch option.Type { - case "number": - _, err := strconv.ParseInt(userValue, 10, 64) - if err != nil { - return fmt.Errorf( - "invalid value %q for option %q, must be a number", - userValue, - optionName, - ) - } - case "boolean": - _, err := strconv.ParseBool(userValue) - if err != nil { - return fmt.Errorf( - "invalid value %q for option %q, must be a boolean", - userValue, - optionName, - ) - } - case "duration": - _, err := time.ParseDuration(userValue) - if err != nil { - return fmt.Errorf( - "invalid value %q for option %q, must be a duration like 10s, 5m or 24h", - userValue, - optionName, - ) - } + case "duration": + if _, err := time.ParseDuration(userValue); err != nil { + return fmt.Errorf( + "invalid value %q for option %q, must be a duration like 10s, 5m or 24h", + userValue, + optionName, + ) } } diff --git a/pkg/options/resolver/resolve.go b/pkg/options/resolver/resolve.go index 57a7c2537..b218d9728 100644 --- a/pkg/options/resolver/resolve.go +++ b/pkg/options/resolver/resolve.go @@ -69,122 +69,226 @@ func (r *Resolver) resolveOption( return err } - // find out options we need to resolve - if !userValueOk { - // check if value is already filled - if beforeValueOk { - if beforeValue.UserProvided || option.Cache == "" { - return nil - } else if option.Cache != "" { - duration, err := time.ParseDuration(option.Cache) - if err != nil { - return fmt.Errorf("parse cache duration of option %s: %w", optionName, err) - } - - // has value expired? - if beforeValue.Filled != nil && beforeValue.Filled.Add(duration).After(time.Now()) { - return nil - } - } - } + skip, err := r.shouldSkipResolve(skipResolveParams{ + optionName: optionName, + option: option, + userValueOk: userValueOk, + beforeValue: beforeValue, + beforeValueOk: beforeValueOk, + }) + if err != nil { + return err + } + if skip { + return nil + } + + if err := r.computeOptionValue(ctx, computeOptionValueParams{ + optionName: optionName, + option: option, + userValue: userValue, + userValueOk: userValueOk, + beforeValue: beforeValue, + resolvedOptionValues: resolvedOptionValues, + }); err != nil { + return err + } + + if err := r.resolveRequired(optionName, option, userValueOk, resolvedOptionValues); err != nil { + return err + } + + r.invalidateChangedChildren(optionName, beforeValue, resolvedOptionValues) + + return nil +} + +type skipResolveParams struct { + optionName string + option *types.Option + userValueOk bool + beforeValue config.OptionValue + beforeValueOk bool +} + +func (r *Resolver) shouldSkipResolve(p skipResolveParams) (bool, error) { + if p.userValueOk { + return false, nil + } - // make sure required is always resolved - if !option.Required { - // skip if global - if !r.resolveGlobal && option.Global { - return nil - } else if !r.resolveLocal && option.Local { - return nil - } + if p.beforeValueOk { + skip, err := beforeValueStillValid(p.optionName, p.option, p.beforeValue) + if err != nil { + return false, err + } + if skip { + return true, nil } } - // resolve option + return r.skipForScope(p.option), nil +} + +func beforeValueStillValid( + optionName string, + option *types.Option, + beforeValue config.OptionValue, +) (bool, error) { + if beforeValue.UserProvided || option.Cache == "" { + return true, nil + } + + duration, err := time.ParseDuration(option.Cache) + if err != nil { + return false, fmt.Errorf("parse cache duration of option %s: %w", optionName, err) + } + + // has value expired? + if beforeValue.Filled != nil && beforeValue.Filled.Add(duration).After(time.Now()) { + return true, nil + } + + return false, nil +} + +func (r *Resolver) skipForScope(option *types.Option) bool { + // make sure required is always resolved + if option.Required { + return false + } + if !r.resolveGlobal && option.Global { + return true + } + if !r.resolveLocal && option.Local { + return true + } + + return false +} + +type computeOptionValueParams struct { + optionName string + option *types.Option + userValue string + userValueOk bool + beforeValue config.OptionValue + resolvedOptionValues map[string]config.OptionValue +} + +func (r *Resolver) computeOptionValue(ctx context.Context, p computeOptionValueParams) error { switch { - case userValueOk: - resolvedOptionValues[optionName] = config.OptionValue{ - Value: userValue, - Children: beforeValue.Children, + case p.userValueOk: + p.resolvedOptionValues[p.optionName] = config.OptionValue{ + Value: p.userValue, + Children: p.beforeValue.Children, UserProvided: true, } - case option.Default != "": - resolvedOptionValues[optionName] = config.OptionValue{ - Children: beforeValue.Children, + case p.option.Default != "": + p.resolvedOptionValues[p.optionName] = config.OptionValue{ + Children: p.beforeValue.Children, Value: ResolveDefaultValue( - option.Default, - combine(resolvedOptionValues, r.extraValues), + p.option.Default, + combine(p.resolvedOptionValues, r.extraValues), ), } - case option.Command != "": - optionValue, err := resolveFromCommand(ctx, option, resolvedOptionValues, r.extraValues) + case p.option.Command != "": + optionValue, err := resolveFromCommand(ctx, p.option, p.resolvedOptionValues, r.extraValues) if err != nil { return err } - optionValue.Children = beforeValue.Children - resolvedOptionValues[optionName] = optionValue - case len(option.Enum) == 1: - resolvedOptionValues[optionName] = config.OptionValue{ - Children: beforeValue.Children, - Value: option.Enum[0].Value, + optionValue.Children = p.beforeValue.Children + p.resolvedOptionValues[p.optionName] = optionValue + case len(p.option.Enum) == 1: + p.resolvedOptionValues[p.optionName] = config.OptionValue{ + Children: p.beforeValue.Children, + Value: p.option.Enum[0].Value, } default: - resolvedOptionValues[optionName] = config.OptionValue{ - Children: beforeValue.Children, + p.resolvedOptionValues[p.optionName] = config.OptionValue{ + Children: p.beforeValue.Children, } } - // is required? - if !userValueOk && option.Required && resolvedOptionValues[optionName].Value == "" && - !resolvedOptionValues[optionName].UserProvided { - if r.skipRequired { - delete(resolvedOptionValues, optionName) - return r.graph.RemoveChildren(optionName) - } + return nil +} - // check if we can ask a question - if !terminal.IsTerminalIn { - return fmt.Errorf("option %s is required, but no value provided", optionName) - } +func (r *Resolver) resolveRequired( + optionName string, + option *types.Option, + userValueOk bool, + resolvedOptionValues map[string]config.OptionValue, +) error { + if userValueOk || !option.Required { + return nil + } - questionOpts := []string{} - for _, enumOpt := range option.Enum { - questionOpts = append(questionOpts, enumOpt.Value) - } + current := resolvedOptionValues[optionName] + if current.Value != "" || current.UserProvided { + return nil + } - // check if there is only one option - log.Info(option.Description) - answer, err := log.QuestionDefault(&survey.QuestionOptions{ - Question: fmt.Sprintf("Enter a value for %s", optionName), - Options: questionOpts, - ValidationRegexPattern: option.ValidationPattern, - ValidationMessage: option.ValidationMessage, - IsPassword: option.Password, - }) - if err != nil { - return err - } + if r.skipRequired { + delete(resolvedOptionValues, optionName) + return r.graph.RemoveChildren(optionName) + } - resolvedOptionValues[optionName] = config.OptionValue{ - Value: answer, - UserProvided: true, - } + return r.askRequired(optionName, option, resolvedOptionValues) +} + +func (r *Resolver) askRequired( + optionName string, + option *types.Option, + resolvedOptionValues map[string]config.OptionValue, +) error { + // check if we can ask a question + if !terminal.IsTerminalIn { + return fmt.Errorf("option %s is required, but no value provided", optionName) } - // check if value has changed - if beforeValue.Value != resolvedOptionValues[optionName].Value { - children := r.graph.GetChildren(optionName) - for _, childID := range children { - optionValue, ok := resolvedOptionValues[childID] - if ok && !optionValue.UserProvided { - delete(resolvedOptionValues, childID) - } - } + questionOpts := []string{} + for _, enumOpt := range option.Enum { + questionOpts = append(questionOpts, enumOpt.Value) + } + + // check if there is only one option + log.Info(option.Description) + answer, err := log.QuestionDefault(&survey.QuestionOptions{ + Question: fmt.Sprintf("Enter a value for %s", optionName), + Options: questionOpts, + ValidationRegexPattern: option.ValidationPattern, + ValidationMessage: option.ValidationMessage, + IsPassword: option.Password, + }) + if err != nil { + return err + } + + resolvedOptionValues[optionName] = config.OptionValue{ + Value: answer, + UserProvided: true, } return nil } +func (r *Resolver) invalidateChangedChildren( + optionName string, + beforeValue config.OptionValue, + resolvedOptionValues map[string]config.OptionValue, +) { + if beforeValue.Value == resolvedOptionValues[optionName].Value { + return + } + + for _, childID := range r.graph.GetChildren(optionName) { + optionValue, ok := resolvedOptionValues[childID] + if ok && !optionValue.UserProvided { + delete(resolvedOptionValues, childID) + } + } +} + func (r *Resolver) getValue( optionName string, option *types.Option, @@ -243,44 +347,56 @@ func (r *Resolver) refreshSubOptions( return err } - for childID := range r.getChangedOptions(r.dynamicOptionsForNode(resolvedOptionValues[optionName].Children), newDynamicOptions, resolvedOptionValues) { - delete(resolvedOptionValues, childID) + r.pruneChangedChildren(optionName, newDynamicOptions, resolvedOptionValues) + r.dropInvalidUserValues(newDynamicOptions) + setChildren(optionName, newDynamicOptions, resolvedOptionValues) + + if err := addOptionsToGraph(r.graph, newDynamicOptions, resolvedOptionValues); err != nil { + return fmt.Errorf("add sub options: %w", err) + } + + if err := resolveDynamicOptions(ctx, newDynamicOptions, r, resolvedOptionValues); err != nil { + return fmt.Errorf("resolve dynamic sub options: %w", err) + } + + return nil +} + +func (r *Resolver) pruneChangedChildren( + optionName string, + newOptions config.OptionDefinitions, + values map[string]config.OptionValue, +) { + for childID := range r.getChangedOptions(r.dynamicOptionsForNode(values[optionName].Children), newOptions, values) { + delete(values, childID) _ = r.graph.RemoveNode(childID) } +} - // remove invalid existing user values - for newOptionName, newOption := range newDynamicOptions { - userValue, ok := r.userOptions[newOptionName] +func (r *Resolver) dropInvalidUserValues(newOptions config.OptionDefinitions) { + for name, option := range newOptions { + userValue, ok := r.userOptions[name] if !ok { continue } - err := validateUserValue(newOptionName, userValue, newOption) - if err != nil { - delete(r.userOptions, newOptionName) + if err := validateUserValue(name, userValue, option); err != nil { + delete(r.userOptions, name) } } +} - // set children on value - val := resolvedOptionValues[optionName] +func setChildren( + optionName string, + newOptions config.OptionDefinitions, + values map[string]config.OptionValue, +) { + val := values[optionName] val.Children = []string{} - for k := range newDynamicOptions { + for k := range newOptions { val.Children = append(val.Children, k) } - resolvedOptionValues[optionName] = val - - // add options to graph - err = addOptionsToGraph(r.graph, newDynamicOptions, resolvedOptionValues) - if err != nil { - return fmt.Errorf("add sub options: %w", err) - } - - err = resolveDynamicOptions(ctx, newDynamicOptions, r, resolvedOptionValues) - if err != nil { - return fmt.Errorf("resolve dynamic sub options: %w", err) - } - - return nil + values[optionName] = val } type queue struct { @@ -348,15 +464,19 @@ func resolveDynamicOptions( processed[opt] = true - for optionName := range subOptions { - if !processed[optionName] { - q.enqueue(optionName) - } - } + enqueueUnprocessed(q, subOptions, processed) } return nil } +func enqueueUnprocessed(q *queue, subOptions config.OptionDefinitions, processed map[string]bool) { + for optionName := range subOptions { + if !processed[optionName] { + q.enqueue(optionName) + } + } +} + func (r *Resolver) retrieveSubOptions( ctx context.Context, optionName string, @@ -377,31 +497,11 @@ func (r *Resolver) retrieveSubOptions( return nil, err } - for childID := range r.getChangedOptions(r.dynamicOptionsForNode(options[optionName].Children), suboptions, options) { - delete(options, childID) - _ = r.graph.RemoveNode(childID) - } + r.pruneChangedChildren(optionName, suboptions, options) + r.dropInvalidUserValues(suboptions) + setChildren(optionName, suboptions, options) - for name, option := range suboptions { - userValue, ok := r.userOptions[name] - if !ok { - continue - } - err := validateUserValue(name, userValue, option) - if err != nil { - delete(r.userOptions, name) - } - } - - val := options[optionName] - val.Children = []string{} - for k := range suboptions { - val.Children = append(val.Children, k) - } - options[optionName] = val - - err = addOptionsToGraph(r.graph, suboptions, options) - if err != nil { + if err := addOptionsToGraph(r.graph, suboptions, options); err != nil { return nil, fmt.Errorf("add sub options: %w", err) } @@ -415,45 +515,48 @@ func (r *Resolver) getChangedOptions( ) config.OptionDefinitions { changedOptions := config.OptionDefinitions{} for oldK, oldV := range oldOptions { - _, ok := newOptions[oldK] - if !ok { + if _, ok := newOptions[oldK]; !ok { changedOptions[oldK] = oldV - continue } } for newK, newV := range newOptions { - oldV, ok := oldOptions[newK] - if !ok { + if optionChanged(oldOptions, newK, newV, resolvedOptionValues) { changedOptions[newK] = newV - continue } + } - oldValue, oldValueOk := resolvedOptionValues[newK] - if !oldValueOk { - changedOptions[newK] = newV - continue - } + return changedOptions +} - enumValues := []string{} - for _, o := range newV.Enum { - enumValues = append(enumValues, o.Value) - } +func optionChanged( + oldOptions config.OptionDefinitions, + newK string, + newV *types.Option, + resolvedOptionValues map[string]config.OptionValue, +) bool { + oldV, ok := oldOptions[newK] + if !ok { + return true + } - // check if value still valid - if len(newV.Enum) > 0 && !contains(enumValues, oldValue.Value) { - changedOptions[newK] = newV - continue - } + oldValue, oldValueOk := resolvedOptionValues[newK] + if !oldValueOk { + return true + } - // check if default has changed - if !oldValue.UserProvided && oldV.Default != newV.Default { - changedOptions[newK] = newV - continue - } + enumValues := []string{} + for _, o := range newV.Enum { + enumValues = append(enumValues, o.Value) } - return changedOptions + // check if value still valid + if len(newV.Enum) > 0 && !contains(enumValues, oldValue.Value) { + return true + } + + // check if default has changed + return !oldValue.UserProvided && oldV.Default != newV.Default } func (r *Resolver) dynamicOptionsForNode(children []string) config.OptionDefinitions { diff --git a/pkg/options/resolver/resolver.go b/pkg/options/resolver/resolver.go index 705ede365..676aab838 100644 --- a/pkg/options/resolver/resolver.go +++ b/pkg/options/resolver/resolver.go @@ -133,7 +133,37 @@ func (r *Resolver) Resolve( return nil, nil, err } - // find out new dynamic options + newDynamicDefinitions := r.findNewDynamicDefinitions(optionDefinitions, resolvedOptions) + + removeStaleOptions(resolvedOptions, newDynamicDefinitions, optionDefinitions) + + // print unused user values + if !r.skipRequired { + printUnusedUserValues( + r.userOptions, + mergeMaps(optionDefinitions, newDynamicDefinitions), + ) + } + + return resolvedOptions, newDynamicDefinitions, nil +} + +func removeStaleOptions( + resolvedOptions map[string]config.OptionValue, + newDynamicDefinitions config.OptionDefinitions, + optionDefinitions map[string]*types.Option, +) { + for k := range resolvedOptions { + if newDynamicDefinitions[k] == nil && optionDefinitions[k] == nil { + delete(resolvedOptions, k) + } + } +} + +func (r *Resolver) findNewDynamicDefinitions( + optionDefinitions map[string]*types.Option, + resolvedOptions map[string]config.OptionValue, +) config.OptionDefinitions { newDynamicDefinitions := config.OptionDefinitions{} for k, option := range r.graph.GetNodes() { if k == rootID || optionDefinitions[k] != nil { @@ -149,20 +179,5 @@ func (r *Resolver) Resolve( } } - // remove options that are not there anymore - for k := range resolvedOptions { - if newDynamicDefinitions[k] == nil && optionDefinitions[k] == nil { - delete(resolvedOptions, k) - } - } - - // print unused user values - if !r.skipRequired { - printUnusedUserValues( - r.userOptions, - mergeMaps(optionDefinitions, newDynamicDefinitions), - ) - } - - return resolvedOptions, newDynamicDefinitions, nil + return newDynamicDefinitions } diff --git a/pkg/options/resolver/util.go b/pkg/options/resolver/util.go index 8c4ef0a2a..c98c92601 100644 --- a/pkg/options/resolver/util.go +++ b/pkg/options/resolver/util.go @@ -42,86 +42,132 @@ func addDependency( optionName string, ) error { option, exists := g.GetNode(optionName) - if !exists { + if !exists || option == nil { return nil } - for _, childName := range optionValues[optionName].Children { + if err := addChildDependencies( + g, + option, + optionName, + optionValues[optionName].Children, + ); err != nil { + return err + } + + if err := addVariableDependencies(variableDependenciesParams{ + g: g, + option: option, + optionName: optionName, + deps: findVariables(option.Default), + kind: "default", + }); err != nil { + return err + } + + return addVariableDependencies(variableDependenciesParams{ + g: g, + option: option, + optionName: optionName, + deps: findVariables(option.Command), + kind: "command", + }) +} + +func addChildDependencies( + g *graph.Graph[*types.Option], + option *types.Option, + optionName string, + children []string, +) error { + for _, childName := range children { if !g.HasNode(childName) || childName == optionName { continue } - childOption, childExists := g.GetNode(childName) - if childExists && childOption != nil { - if !option.Global && childOption.Global { - return fmt.Errorf( - "cannot use a global option as a dependency of a non-global option. Option %q used in children of option %q", - childName, - optionName, - ) - } - if option.Local && !childOption.Local { - return fmt.Errorf( - "cannot use a non-local option as a dependency of a local option. Option %q used in children of option %q", - childName, - optionName, - ) - } + if err := validateChildDependency(g, option, optionName, childName); err != nil { + return err } _ = g.AddEdge(optionName, childName) } - for _, dep := range findVariables(option.Default) { - if !g.HasNode(dep) || dep == optionName { - continue - } + return nil +} - depOption, depExists := g.GetNode(dep) - if depExists && depOption != nil { - if option.Global && !depOption.Global { - return fmt.Errorf( - "cannot use a non-global option as a dependency of a global option. Option %q used in default of option %q", - dep, - optionName, - ) - } - if !option.Local && depOption.Local { - return fmt.Errorf( - "cannot use a local option as a dependency of a non-local option. Option %q used in default of option %q", - dep, - optionName, - ) - } - } +func validateChildDependency( + g *graph.Graph[*types.Option], + option *types.Option, + optionName, childName string, +) error { + childOption, childExists := g.GetNode(childName) + if !childExists || childOption == nil { + return nil + } - _ = g.AddEdge(dep, optionName) + if !option.Global && childOption.Global { + return fmt.Errorf( + "cannot use a global option as a dependency of a non-global option. Option %q used in children of option %q", + childName, + optionName, + ) + } + if option.Local && !childOption.Local { + return fmt.Errorf( + "cannot use a non-local option as a dependency of a local option. Option %q used in children of option %q", + childName, + optionName, + ) } - for _, dep := range findVariables(option.Command) { - if !g.HasNode(dep) || dep == optionName { + return nil +} + +type variableDependenciesParams struct { + g *graph.Graph[*types.Option] + option *types.Option + optionName string + deps []string + kind string +} + +func addVariableDependencies(p variableDependenciesParams) error { + for _, dep := range p.deps { + if !p.g.HasNode(dep) || dep == p.optionName { continue } - depOption, depExists := g.GetNode(dep) - if depExists && depOption != nil { - if option.Global && !depOption.Global { - return fmt.Errorf( - "cannot use a non-global option as a dependency of a global option. Option %q used in command of option %q", - dep, - optionName, - ) - } - if !option.Local && depOption.Local { - return fmt.Errorf( - "cannot use a local option as a dependency of a non-local option. Option %q used in command of option %q", - dep, - optionName, - ) - } + if err := validateVariableDependency(p, dep); err != nil { + return err } - _ = g.AddEdge(dep, optionName) + _ = p.g.AddEdge(dep, p.optionName) + } + + return nil +} + +func validateVariableDependency(p variableDependenciesParams, dep string) error { + depOption, depExists := p.g.GetNode(dep) + if !depExists || depOption == nil { + return nil + } + + if p.option.Global && !depOption.Global { + return fmt.Errorf( + "cannot use a non-global option as a dependency of a global option. Option %q used in %s of option %q", + dep, + p.kind, + p.optionName, + ) + } + if !p.option.Local && depOption.Local { + return fmt.Errorf( + "cannot use a local option as a dependency of a non-local option. Option %q used in %s of option %q", + dep, + p.kind, + p.optionName, + ) } return nil diff --git a/pkg/platform/client/client.go b/pkg/platform/client/client.go index 060c98fd4..2759cb585 100644 --- a/pkg/platform/client/client.go +++ b/pkg/platform/client/client.go @@ -367,27 +367,42 @@ func (c *client) LoginWithAccessKey(host, accessKey string, insecure bool, force // delete old access key if were logged in before if !force && c.config.AccessKey != "" { - managementClient, err := c.Management() - if err == nil { - self, err := managementClient.Loft(). - ManagementV1(). - Selves(). - Create(context.TODO(), &managementv1.Self{}, metav1.CreateOptions{}) - if err == nil && self.Status.AccessKey != "" && - self.Status.AccessKeyType == storagev1.AccessKeyTypeLogin { - _ = managementClient.Loft(). - ManagementV1(). - OwnedAccessKeys(). - Delete(context.TODO(), self.Status.AccessKey, metav1.DeleteOptions{}) - } - } + c.deleteOldAccessKey() } c.config.Host = host c.config.Insecure = insecure c.config.AccessKey = accessKey - // verify the connection works + if err := c.verifyConnection(); err != nil { + return err + } + + return c.Save() +} + +func (c *client) deleteOldAccessKey() { + managementClient, err := c.Management() + if err != nil { + return + } + + self, err := managementClient.Loft(). + ManagementV1(). + Selves(). + Create(context.TODO(), &managementv1.Self{}, metav1.CreateOptions{}) + if err != nil { + return + } + if self.Status.AccessKey != "" && self.Status.AccessKeyType == storagev1.AccessKeyTypeLogin { + _ = managementClient.Loft(). + ManagementV1(). + OwnedAccessKeys(). + Delete(context.TODO(), self.Status.AccessKey, metav1.DeleteOptions{}) + } +} + +func (c *client) verifyConnection() error { managementClient, err := c.Management() if err != nil { return fmt.Errorf("create management client: %w", err) @@ -398,22 +413,22 @@ func (c *client) LoginWithAccessKey(host, accessKey string, insecure bool, force ManagementV1(). Selves(). Create(context.TODO(), &managementv1.Self{}, metav1.CreateOptions{}) - if err != nil { - var urlError *url.Error - if errors.As(err, &urlError) { - var err x509.UnknownAuthorityError - if errors.As(urlError.Err, &err) { - return fmt.Errorf( - "unsafe login endpoint %q, if you wish to login into an insecure loft endpoint run with the '--insecure' flag", - c.config.Host, - ) - } - } + if err == nil { + return nil + } - return fmt.Errorf("error logging in: %w", err) + var urlError *url.Error + if errors.As(err, &urlError) { + var authErr x509.UnknownAuthorityError + if errors.As(urlError.Err, &authErr) { + return fmt.Errorf( + "unsafe login endpoint %q, if you wish to login into an insecure loft endpoint run with the '--insecure' flag", + c.config.Host, + ) + } } - return c.Save() + return fmt.Errorf("error logging in: %w", err) } // VerifyVersion checks if the Loft version is compatible with this CLI version. diff --git a/pkg/platform/deploy.go b/pkg/platform/deploy.go index 3f4d78d57..192aa8fae 100644 --- a/pkg/platform/deploy.go +++ b/pkg/platform/deploy.go @@ -41,111 +41,13 @@ func WaitForPodReady( Timeout(), true, func(ctx context.Context) (bool, error) { - pods, err := kubeClient.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{ - LabelSelector: "app=devsy", - }) + loftPod, found, err := pollLoftPodReady(ctx, kubeClient, namespace, &now) if err != nil { - log.Warnf("Error trying to retrieve %s pod: %v", pkgconfig.ProductNamePro, err) - return false, nil - } else if len(pods.Items) == 0 { - if time.Now().After(now.Add(time.Second * 10)) { - log.Infof("Still waiting for a %s pod", pkgconfig.ProductNamePro) - now = time.Now() - } - return false, nil + return false, err } - - sort.Slice(pods.Items, func(i, j int) bool { - return pods.Items[i].CreationTimestamp.After(pods.Items[j].CreationTimestamp.Time) - }) - - loftPod := &pods.Items[0] - found := false - for _, containerStatus := range loftPod.Status.ContainerStatuses { - switch { - case containerStatus.State.Running != nil && containerStatus.Ready: - if containerStatus.Name == "manager" { - found = true - } - - continue - case containerStatus.State.Terminated != nil || - (containerStatus.State.Waiting != nil && CriticalStatus[containerStatus.State.Waiting.Reason]): - reason := "" - message := "" - if containerStatus.State.Terminated != nil { - reason = containerStatus.State.Terminated.Reason - message = containerStatus.State.Terminated.Message - } else if containerStatus.State.Waiting != nil { - reason = containerStatus.State.Waiting.Reason - message = containerStatus.State.Waiting.Message - } - - out, err := kubeClient.CoreV1(). - Pods(namespace). - GetLogs(loftPod.Name, &corev1.PodLogOptions{ - Container: "manager", - }). - Do(context.Background()). - Raw() - if err != nil { - return false, fmt.Errorf( - "there seems to be an issue with %s starting up: %s (%s). Reach out to our support at https://devsy.sh/", - pkgconfig.ProductNamePro, - message, - reason, - ) - } - if strings.Contains( - string(out), - "register instance: Post \"https://license.devsy.sh/register\": dial tcp", - ) { - return false, fmt.Errorf( - "%[1]s logs: \n%[2]v \nThere seems to be an issue with %[1]s starting up. "+ - "Looks like you try to install %[1]s into an air-gapped environment, "+ - "reach out to our support at https://devsy.sh/ for an offline license", - pkgconfig.ProductNamePro, - string(out), - ) - } - - return false, fmt.Errorf( - "%[1]s logs: \n%v \nThere seems to be an issue with %[1]s starting up: %[2]s (%[3]s). "+ - "Reach out to our support at https://devsy.sh/", - pkgconfig.ProductNamePro, - string(out), - message, - reason, - ) - case containerStatus.State.Waiting != nil && time.Now().After(now.Add(time.Second*10)): - switch { - case containerStatus.State.Waiting.Message != "": - log.Infof( - "Keep waiting, %s container is still starting up: %s (%s)", - pkgconfig.ProductNamePro, - containerStatus.State.Waiting.Message, - containerStatus.State.Waiting.Reason, - ) - case containerStatus.State.Waiting.Reason != "": - log.Infof( - "Keep waiting, %s container is still starting up: %s", - pkgconfig.ProductNamePro, - containerStatus.State.Waiting.Reason, - ) - default: - log.Infof( - "Keep waiting, %s container is still starting up", - pkgconfig.ProductNamePro, - ) - } - - now = time.Now() - } - - return false, nil + if loftPod != nil { + pod = loftPod } - - pod = loftPod return found, nil }, ) @@ -155,3 +57,165 @@ func WaitForPodReady( return pod, nil } + +func pollLoftPodReady( + ctx context.Context, + kubeClient kubernetes.Interface, + namespace string, + now *time.Time, +) (*corev1.Pod, bool, error) { + loftPod := latestLoftPod(ctx, kubeClient, namespace, now) + if loftPod == nil { + return nil, false, nil + } + + found := false + for i := range loftPod.Status.ContainerStatuses { + containerStatus := loftPod.Status.ContainerStatuses[i] + ready, err := evalContainerStatus(evalContainerStatusParams{ + ctx: ctx, + kubeClient: kubeClient, + namespace: namespace, + loftPod: loftPod, + containerStatus: containerStatus, + now: now, + }) + if err != nil { + return nil, false, err + } + if !ready { + return nil, false, nil + } + if containerStatus.Name == "manager" { + found = true + } + } + + return loftPod, found, nil +} + +func latestLoftPod( + ctx context.Context, + kubeClient kubernetes.Interface, + namespace string, + now *time.Time, +) *corev1.Pod { + pods, err := kubeClient.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: "app=devsy", + }) + if err != nil { + log.Warnf("Error trying to retrieve %s pod: %v", pkgconfig.ProductNamePro, err) + return nil + } + if len(pods.Items) == 0 { + if time.Now().After(now.Add(time.Second * 10)) { + log.Infof("Still waiting for a %s pod", pkgconfig.ProductNamePro) + *now = time.Now() + } + return nil + } + + sort.Slice(pods.Items, func(i, j int) bool { + return pods.Items[i].CreationTimestamp.After(pods.Items[j].CreationTimestamp.Time) + }) + + return &pods.Items[0] +} + +type evalContainerStatusParams struct { + ctx context.Context + kubeClient kubernetes.Interface + namespace string + loftPod *corev1.Pod + containerStatus corev1.ContainerStatus + now *time.Time +} + +func evalContainerStatus(params evalContainerStatusParams) (bool, error) { + containerStatus := params.containerStatus + switch { + case containerStatus.State.Running != nil && containerStatus.Ready: + return true, nil + case containerStatus.State.Terminated != nil || + (containerStatus.State.Waiting != nil && CriticalStatus[containerStatus.State.Waiting.Reason]): + return false, loftPodFailureError(params) + case containerStatus.State.Waiting != nil && time.Now().After(params.now.Add(time.Second*10)): + logContainerWaiting(containerStatus) + *params.now = time.Now() + } + + return false, nil +} + +func loftPodFailureError(params evalContainerStatusParams) error { + containerStatus := params.containerStatus + reason := "" + message := "" + if containerStatus.State.Terminated != nil { + reason = containerStatus.State.Terminated.Reason + message = containerStatus.State.Terminated.Message + } else if containerStatus.State.Waiting != nil { + reason = containerStatus.State.Waiting.Reason + message = containerStatus.State.Waiting.Message + } + + out, err := params.kubeClient.CoreV1(). + Pods(params.namespace). + GetLogs(params.loftPod.Name, &corev1.PodLogOptions{ + Container: "manager", + }). + Do(params.ctx). + Raw() + if err != nil { + return fmt.Errorf( + "there seems to be an issue with %s starting up: %s (%s). Reach out to our support at https://devsy.sh/", + pkgconfig.ProductNamePro, + message, + reason, + ) + } + if strings.Contains( + string(out), + "register instance: Post \"https://license.devsy.sh/register\": dial tcp", + ) { + return fmt.Errorf( + "%[1]s logs: \n%[2]v \nThere seems to be an issue with %[1]s starting up. "+ + "Looks like you try to install %[1]s into an air-gapped environment, "+ + "reach out to our support at https://devsy.sh/ for an offline license", + pkgconfig.ProductNamePro, + string(out), + ) + } + + return fmt.Errorf( + "%[1]s logs: \n%[2]v \nThere seems to be an issue with %[1]s starting up: %[3]s (%[4]s). "+ + "Reach out to our support at https://devsy.sh/", + pkgconfig.ProductNamePro, + string(out), + message, + reason, + ) +} + +func logContainerWaiting(containerStatus corev1.ContainerStatus) { + switch { + case containerStatus.State.Waiting.Message != "": + log.Infof( + "Keep waiting, %s container is still starting up: %s (%s)", + pkgconfig.ProductNamePro, + containerStatus.State.Waiting.Message, + containerStatus.State.Waiting.Reason, + ) + case containerStatus.State.Waiting.Reason != "": + log.Infof( + "Keep waiting, %s container is still starting up: %s", + pkgconfig.ProductNamePro, + containerStatus.State.Waiting.Reason, + ) + default: + log.Infof( + "Keep waiting, %s container is still starting up", + pkgconfig.ProductNamePro, + ) + } +} diff --git a/pkg/platform/form/form.go b/pkg/platform/form/form.go index 03bc51916..2a6b86633 100644 --- a/pkg/platform/form/form.go +++ b/pkg/platform/form/form.go @@ -21,6 +21,8 @@ import ( "sigs.k8s.io/yaml" ) +const parameterTypeBoolean = "boolean" + func CreateInstance( ctx context.Context, baseClient client.Client, @@ -135,20 +137,10 @@ func UpdateInstance( if err != nil { return nil, err } - var selectedTemplate *managementv1.DevsyWorkspaceTemplate - templateOptions := []TemplateOption{} - for _, template := range projectTemplates.DevsyWorkspaceTemplates { - t := &template - templateOptions = append(templateOptions, huh.Option[*managementv1.DevsyWorkspaceTemplate]{ - Key: platform.DisplayName(template.GetName(), template.Spec.DisplayName), - Value: t, - }) - - if instance.Spec.TemplateRef != nil && - instance.Spec.TemplateRef.Name == template.GetName() { - selectedTemplate = t - } - } + templateOptions, selectedTemplate := templateOptionsForInstance( + projectTemplates.DevsyWorkspaceTemplates, + instance, + ) if selectedTemplate == nil { return nil, fmt.Errorf("template not found: %#v", instance.Spec.TemplateRef) } @@ -177,81 +169,177 @@ func UpdateInstance( return nil, err } + renderedParameters, err := renderedParametersForUpdate( + formCtx, + instance, + selectedTemplate, + selectedTemplateVersion, + ) + if err != nil { + return nil, err + } + + newInstance := instance.DeepCopy() + applyInstanceChanges(applyInstanceChangesParams{ + instance: instance, + newInstance: newInstance, + selectedTemplate: selectedTemplate, + selectedTemplateVersion: selectedTemplateVersion, + renderedParameters: renderedParameters, + }) + + return newInstance, nil +} + +func templateOptionsForInstance( + templates []managementv1.DevsyWorkspaceTemplate, + instance *managementv1.DevsyWorkspaceInstance, +) ([]TemplateOption, *managementv1.DevsyWorkspaceTemplate) { + var selectedTemplate *managementv1.DevsyWorkspaceTemplate + templateOptions := []TemplateOption{} + for _, template := range templates { + t := &template + templateOptions = append(templateOptions, huh.Option[*managementv1.DevsyWorkspaceTemplate]{ + Key: platform.DisplayName(template.GetName(), template.Spec.DisplayName), + Value: t, + }) + + if instance.Spec.TemplateRef != nil && + instance.Spec.TemplateRef.Name == template.GetName() { + selectedTemplate = t + } + } + + return templateOptions, selectedTemplate +} + +func renderedParametersForUpdate( + formCtx context.Context, + instance *managementv1.DevsyWorkspaceInstance, + selectedTemplate *managementv1.DevsyWorkspaceTemplate, + selectedTemplateVersion string, +) (string, error) { parameters := selectedTemplate.Spec.Parameters if len(selectedTemplate.GetVersions()) > 0 { + var err error parameters, err = list.GetTemplateParameters(selectedTemplate, selectedTemplateVersion) if err != nil { - return nil, err + return "", err } } + if len(parameters) == 0 { + return "", nil + } - renderedParameters := "" - if len(parameters) > 0 { - tRef := instance.Spec.TemplateRef - var existingParameters map[string]any - if tRef != nil && tRef.Name == selectedTemplate.GetName() && - tRef.Version == selectedTemplateVersion { - existingParameters = map[string]any{} - err = yaml.Unmarshal([]byte(instance.Spec.Parameters), &existingParameters) - if err != nil { - return nil, err - } - } + fieldParameters, err := buildFieldParameters( + parameters, + instance, + selectedTemplate, + selectedTemplateVersion, + ) + if err != nil { + return "", err + } - fieldParameters := []*FieldParameter{} - // reuse existing parameters as starting point - for _, p := range parameters { - var value any = p.DefaultValue - if existingParameters != nil { - value = getDeepValue(existingParameters, p.Variable) - } - fieldParameter := FieldParameter{AppParameter: p} - - if p.Type == "boolean" && value != nil { - v, err := strconv.ParseBool(value.(string)) - if err == nil { - fieldParameter.BoolValue = v - } - } else { - if value != nil { - fieldParameter.StringValue = value.(string) - } else { - fieldParameter.StringValue = p.DefaultValue - } - } - fieldParameters = append(fieldParameters, &fieldParameter) - } + err = huh.NewForm( + huh.NewGroup(parameterFields(fieldParameters)...), + ).RunWithContext(formCtx) + if err != nil { + return "", err + } - err = huh.NewForm( - huh.NewGroup(parameterFields(fieldParameters)...), - ).RunWithContext(formCtx) - if err != nil { + return renderParameters(fieldParameters) +} + +func buildFieldParameters( + parameters []storagev1.AppParameter, + instance *managementv1.DevsyWorkspaceInstance, + selectedTemplate *managementv1.DevsyWorkspaceTemplate, + selectedTemplateVersion string, +) ([]*FieldParameter, error) { + tRef := instance.Spec.TemplateRef + var existingParameters map[string]any + if tRef != nil && tRef.Name == selectedTemplate.GetName() && + tRef.Version == selectedTemplateVersion { + existingParameters = map[string]any{} + if err := yaml.Unmarshal( + []byte(instance.Spec.Parameters), + &existingParameters, + ); err != nil { return nil, err } + } - renderedParameters, err = renderParameters(fieldParameters) - if err != nil { - return nil, err + fieldParameters := []*FieldParameter{} + // reuse existing parameters as starting point + for _, p := range parameters { + var value any = p.DefaultValue + if existingParameters != nil { + value = getDeepValue(existingParameters, p.Variable) } + fieldParameter := FieldParameter{AppParameter: p} + assignFieldValue(&fieldParameter, value) + fieldParameters = append(fieldParameters, &fieldParameter) } - newInstance := instance.DeepCopy() + return fieldParameters, nil +} + +func assignFieldValue(fieldParameter *FieldParameter, value any) { + if fieldParameter.Type == parameterTypeBoolean { + assignBoolFieldValue(fieldParameter, value) + return + } + + if s, ok := value.(string); ok { + fieldParameter.StringValue = s + } else if value != nil { + fieldParameter.StringValue = fmt.Sprintf("%v", value) + } else { + fieldParameter.StringValue = fieldParameter.DefaultValue + } +} + +func assignBoolFieldValue(fieldParameter *FieldParameter, value any) { + switch v := value.(type) { + case bool: + fieldParameter.BoolValue = v + case string: + if b, err := strconv.ParseBool(v); err == nil { + fieldParameter.BoolValue = b + } + default: + if b, err := strconv.ParseBool(fieldParameter.DefaultValue); err == nil { + fieldParameter.BoolValue = b + } + } +} + +type applyInstanceChangesParams struct { + instance *managementv1.DevsyWorkspaceInstance + newInstance *managementv1.DevsyWorkspaceInstance + selectedTemplate *managementv1.DevsyWorkspaceTemplate + selectedTemplateVersion string + renderedParameters string +} + +func applyInstanceChanges(params applyInstanceChangesParams) { + instance := params.instance + newInstance := params.newInstance // template if instance.Spec.TemplateRef != nil && - instance.Spec.TemplateRef.Name != selectedTemplate.GetName() { - newInstance.Spec.TemplateRef.Name = selectedTemplate.GetName() + instance.Spec.TemplateRef.Name != params.selectedTemplate.GetName() { + newInstance.Spec.TemplateRef.Name = params.selectedTemplate.GetName() } // version if instance.Spec.TemplateRef != nil && - instance.Spec.TemplateRef.Version != selectedTemplateVersion { - newInstance.Spec.TemplateRef.Version = selectedTemplateVersion + instance.Spec.TemplateRef.Version != params.selectedTemplateVersion { + newInstance.Spec.TemplateRef.Version = params.selectedTemplateVersion } // parameters - if instance.Spec.Parameters != renderedParameters { - newInstance.Spec.Parameters = renderedParameters + if instance.Spec.Parameters != params.renderedParameters { + newInstance.Spec.Parameters = params.renderedParameters } - - return newInstance, nil } type ( @@ -380,7 +468,7 @@ func prepareParameters(parameters []storagev1.AppParameter) []*FieldParameter { retParams := []*FieldParameter{} for _, p := range parameters { fieldParameter := FieldParameter{AppParameter: p} - if p.Type == "boolean" { + if p.Type == parameterTypeBoolean { v, err := strconv.ParseBool(p.DefaultValue) if err == nil { fieldParameter.BoolValue = v @@ -410,49 +498,15 @@ func parameterFields(fieldParameters []*FieldParameter) []huh.Field { Title(title). Description(param.Description). Value(¶m.StringValue) - case "password": - fallthrough - case "number": - fallthrough - case "string": - // display a select field if param has options - if len(param.Options) > 0 { - opts := []huh.Option[string]{} - for _, o := range param.Options { - huhOption := huh.Option[string]{ - Key: o, - Value: o, - } - if o == param.DefaultValue { - huhOption = huhOption.Selected(true) - } - opts = append(opts, huhOption) - } - field = huh.NewSelect[string](). - Title(title). - Options(opts...). - Value(¶m.StringValue) - } else { - input := huh.NewInput().Title(title). - Description(param.Description). - Value(¶m.StringValue) - - if param.Type == "password" { - input.EchoMode(huh.EchoModePassword) - } - if param.Type == "number" { - input.Validate(func(s string) error { - _, err := strconv.ParseFloat(s, 64) - return err - }) - } - field = input - } - case "boolean": + case "password", "number", "string": + field = stringParameterField(param, title) + case parameterTypeBoolean: field = huh.NewConfirm(). Title(title). Description(param.Description). Value(¶m.BoolValue) + default: + field = stringParameterField(param, title) } fields = append(fields, field) @@ -461,6 +515,43 @@ func parameterFields(fieldParameters []*FieldParameter) []huh.Field { return fields } +func stringParameterField(param *FieldParameter, title string) huh.Field { + // display a select field if param has options + if len(param.Options) > 0 { + opts := []huh.Option[string]{} + for _, o := range param.Options { + huhOption := huh.Option[string]{ + Key: o, + Value: o, + } + if o == param.DefaultValue { + huhOption = huhOption.Selected(true) + } + opts = append(opts, huhOption) + } + return huh.NewSelect[string](). + Title(title). + Options(opts...). + Value(¶m.StringValue) + } + + input := huh.NewInput().Title(title). + Description(param.Description). + Value(¶m.StringValue) + + if param.Type == "password" { + input.EchoMode(huh.EchoModePassword) + } + if param.Type == "number" { + input.Validate(func(s string) error { + _, err := strconv.ParseFloat(s, 64) + return err + }) + } + + return input +} + func renderParameters(fieldParameters []*FieldParameter) (string, error) { p := map[string]any{} for _, fp := range fieldParameters { @@ -487,29 +578,37 @@ func getDeepValue(parameters any, path string) any { pathSegments := strings.Split(path, ".") switch t := parameters.(type) { case map[string]any: - val, ok := t[pathSegments[0]] - if !ok { - return nil - } else if len(pathSegments) == 1 { - return val - } - - return getDeepValue(val, strings.Join(pathSegments[1:], ".")) + return getDeepValueFromMap(t, pathSegments) case []any: - index, err := strconv.Atoi(pathSegments[0]) - if err != nil { - return nil - } else if index < 0 || index >= len(t) { - return nil - } + return getDeepValueFromSlice(t, pathSegments) + } - val := t[index] - if len(pathSegments) == 1 { - return val - } + return nil +} - return getDeepValue(val, strings.Join(pathSegments[1:], ".")) +func getDeepValueFromMap(t map[string]any, pathSegments []string) any { + val, ok := t[pathSegments[0]] + if !ok { + return nil + } else if len(pathSegments) == 1 { + return val } - return nil + return getDeepValue(val, strings.Join(pathSegments[1:], ".")) +} + +func getDeepValueFromSlice(t []any, pathSegments []string) any { + index, err := strconv.Atoi(pathSegments[0]) + if err != nil { + return nil + } else if index < 0 || index >= len(t) { + return nil + } + + val := t[index] + if len(pathSegments) == 1 { + return val + } + + return getDeepValue(val, strings.Join(pathSegments[1:], ".")) } diff --git a/pkg/platform/instance.go b/pkg/platform/instance.go index b939c5951..f63cbed8c 100644 --- a/pkg/platform/instance.go +++ b/pkg/platform/instance.go @@ -273,77 +273,102 @@ func WaitForInstance( var updatedInstance *managementv1.DevsyWorkspaceInstance // we need to wait until instance is scheduled - err = wait.PollUntilContextTimeout( - ctx, - time.Second, - 30*time.Second, - true, - func(ctx context.Context) (done bool, err error) { - updatedInstance, err = managementClient.Loft().ManagementV1(). - DevsyWorkspaceInstances(instance.GetNamespace()). - Get(ctx, instance.GetName(), metav1.GetOptions{}) - if err != nil { - return false, err - } - name := updatedInstance.GetName() - status := updatedInstance.Status + poll := func(ctx context.Context) (bool, error) { + fetched, ready, pollErr := getInstanceIfReady(ctx, managementClient, instance) + if pollErr != nil { + return false, pollErr + } + updatedInstance = fetched + return ready, nil + } - if !isReady(updatedInstance) { - log.Debugf( - "Workspace %s is in phase %s, waiting until its ready", - name, - status.Phase, - ) - return false, nil - } + err = wait.PollUntilContextTimeout(ctx, time.Second, 30*time.Second, true, poll) + if err != nil { + return nil, waitForInstanceTimeoutError(updatedInstance, err) + } - if !isTemplateSynced(updatedInstance) { - log.Debugf("Workspace template is not ready yet") - for _, cond := range updatedInstance.Status.Conditions { - if cond.Status != corev1.ConditionTrue { - log.Debugf( - "%s is %s (%s): %s", - cond.Type, - cond.Status, - cond.Reason, - cond.Message, - ) - } - } - return false, nil - } + return updatedInstance, nil +} - log.Debugf("Workspace %s is ready", name) - return true, nil - }, - ) +func getInstanceIfReady( + ctx context.Context, + managementClient kube.Interface, + instance *managementv1.DevsyWorkspaceInstance, +) (*managementv1.DevsyWorkspaceInstance, bool, error) { + updatedInstance, err := managementClient.Loft().ManagementV1(). + DevsyWorkspaceInstances(instance.GetNamespace()). + Get(ctx, instance.GetName(), metav1.GetOptions{}) if err != nil { - // let's build a proper error message here - var msg strings.Builder - msg.WriteString("Timed out waiting for workspace to get ready \n\n ") - // basic status - fmt.Fprintf(&msg, "ready: %t\n", isReady(updatedInstance)) - fmt.Fprintf(&msg, "template synced: %t\n", isTemplateSynced(updatedInstance)) - msg.WriteString("\n") - - // CRD conditions - msg.WriteString("Conditions:\n") + return nil, false, err + } + + return updatedInstance, instanceReady(updatedInstance), nil +} + +func instanceReady(updatedInstance *managementv1.DevsyWorkspaceInstance) bool { + name := updatedInstance.GetName() + status := updatedInstance.Status + + if !isReady(updatedInstance) { + log.Debugf( + "Workspace %s is in phase %s, waiting until its ready", + name, + status.Phase, + ) + return false + } + + if !isTemplateSynced(updatedInstance) { + log.Debugf("Workspace template is not ready yet") for _, cond := range updatedInstance.Status.Conditions { - fmt.Fprintf(&msg, "%s is %s (%s): %s\n", - cond.Type, - cond.Status, - cond.Reason, - cond.Message) + if cond.Status != corev1.ConditionTrue { + log.Debugf( + "%s is %s (%s): %s", + cond.Type, + cond.Status, + cond.Reason, + cond.Message, + ) + } } - msg.WriteString("\n") + return false + } + + log.Debugf("Workspace %s is ready", name) + return true +} - // error message, usually context timeout - fmt.Fprintf(&msg, "Error: %s", err.Error()) +func waitForInstanceTimeoutError( + updatedInstance *managementv1.DevsyWorkspaceInstance, + err error, +) error { + if updatedInstance == nil { + return fmt.Errorf("timed out waiting for workspace to get ready: %w", err) + } - return nil, errors.New(msg.String()) + // let's build a proper error message here + var msg strings.Builder + msg.WriteString("Timed out waiting for workspace to get ready \n\n ") + // basic status + fmt.Fprintf(&msg, "ready: %t\n", isReady(updatedInstance)) + fmt.Fprintf(&msg, "template synced: %t\n", isTemplateSynced(updatedInstance)) + msg.WriteString("\n") + + // CRD conditions + msg.WriteString("Conditions:\n") + for _, cond := range updatedInstance.Status.Conditions { + fmt.Fprintf(&msg, "%s is %s (%s): %s\n", + cond.Type, + cond.Status, + cond.Reason, + cond.Message) } + msg.WriteString("\n") - return updatedInstance, nil + // error message, usually context timeout + fmt.Fprintf(&msg, "Error: %s", err.Error()) + + return errors.New(msg.String()) } func isReady(workspace *managementv1.DevsyWorkspaceInstance) bool { diff --git a/pkg/platform/kubeconfig.go b/pkg/platform/kubeconfig.go index 21af0efdf..1c8daf446 100644 --- a/pkg/platform/kubeconfig.go +++ b/pkg/platform/kubeconfig.go @@ -11,6 +11,7 @@ import ( "github.com/devsy-org/api/pkg/devsy" "github.com/devsy-org/devsy/pkg/platform/annotations" "github.com/devsy-org/devsy/pkg/platform/client" + "github.com/devsy-org/devsy/pkg/platform/kube" "github.com/devsy-org/devsy/pkg/platform/project" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/tools/clientcmd" @@ -25,68 +26,75 @@ func NewInstanceKubeConfig( ctx context.Context, platformOptions *devsy.PlatformOptions, ) ([]byte, error) { - if platformOptions == nil { + if platformOptions == nil || platformOptions.Kubernetes == nil { return nil, nil } - kube := platformOptions.Kubernetes - if kube == nil { - return nil, nil - } - accessKey := platformOptions.UserAccessKey - if accessKey == "" { - return nil, fmt.Errorf("user access key missing") - } - host := platformOptions.PlatformHost - if host == "" { - return nil, fmt.Errorf("platform host is missing") + + skip, err := validateKubeConfigOptions(platformOptions) + if err != nil { + return nil, err } - if kube.SpaceName == "" && kube.VirtualClusterName == "" { - // nothing to do here + if skip { return nil, nil } - if kube.SpaceName != "" && kube.VirtualClusterName != "" { - return nil, fmt.Errorf("cannot use virtual cluster and space instance together") - } - if kube.Namespace == "" { - return nil, fmt.Errorf("namespace missing") - } + k8sOpts := platformOptions.Kubernetes baseClient := client.NewClientFromConfig(&client.Config{ - AccessKey: accessKey, - Host: "https://" + host, + AccessKey: platformOptions.UserAccessKey, + Host: "https://" + platformOptions.PlatformHost, Insecure: true, }) - err := baseClient.RefreshSelf(ctx) - if err != nil { + if err := baseClient.RefreshSelf(ctx); err != nil { return nil, fmt.Errorf("refresh self: %w", err) } - var kubeConfig *clientcmdapi.Config - if kube.SpaceName != "" { - kubeConfig, err = kubeConfigForSpaceInstance( - ctx, - baseClient, - kube.SpaceName, - kube.Namespace, - ) - if err != nil { - return nil, err - } - } else if kube.VirtualClusterName != "" { - kubeConfig, err = kubeConfigForVirtualClusterInstance( - ctx, - baseClient, - kube.VirtualClusterName, - kube.Namespace, - ) - if err != nil { - return nil, err - } + kubeConfig, err := kubeConfigForTarget(ctx, baseClient, k8sOpts) + if err != nil { + return nil, err } return clientcmd.Write(*kubeConfig) } +func validateKubeConfigOptions(platformOptions *devsy.PlatformOptions) (bool, error) { + if platformOptions.UserAccessKey == "" { + return false, fmt.Errorf("user access key missing") + } + if platformOptions.PlatformHost == "" { + return false, fmt.Errorf("platform host is missing") + } + k8sOpts := platformOptions.Kubernetes + if k8sOpts.SpaceName == "" && k8sOpts.VirtualClusterName == "" { + // nothing to do here + return true, nil + } + if k8sOpts.SpaceName != "" && k8sOpts.VirtualClusterName != "" { + return false, fmt.Errorf("cannot use virtual cluster and space instance together") + } + if k8sOpts.Namespace == "" { + return false, fmt.Errorf("namespace missing") + } + + return false, nil +} + +func kubeConfigForTarget( + ctx context.Context, + baseClient client.Client, + k8sOpts *devsy.Kubernetes, +) (*clientcmdapi.Config, error) { + if k8sOpts.SpaceName != "" { + return kubeConfigForSpaceInstance(ctx, baseClient, k8sOpts.SpaceName, k8sOpts.Namespace) + } + + return kubeConfigForVirtualClusterInstance( + ctx, + baseClient, + k8sOpts.VirtualClusterName, + k8sOpts.Namespace, + ) +} + func kubeConfigForSpaceInstance( ctx context.Context, baseClient client.Client, @@ -220,31 +228,20 @@ func kubeConfigForVirtualClusterInstance( VirtualCluster: virtualClusterInstance.Name, }}, } - ttl := int64(configTTL.Seconds()) + req := vClusterKubeConfigRequest{ + ctx: ctx, + managementClient: managementClient, + namespace: namespace, + projectName: projectName, + scope: scope, + ttl: int64(configTTL.Seconds()), + instance: virtualClusterInstance, + } // direct virtual cluster ingress access? virtualCluster := virtualClusterInstance.Status.VirtualCluster if virtualCluster != nil && virtualCluster.AccessPoint.Ingress.Enabled { - certTTL := int32(ttl) - config := &managementv1.VirtualClusterInstanceKubeConfig{ - Spec: managementv1.VirtualClusterInstanceKubeConfigSpec{ - CertificateTTL: &certTTL, - }, - } - directVirtualClusterKubeConfig, err := managementClient.Loft(). - ManagementV1(). - VirtualClusterInstances(namespace). - GetKubeConfig(ctx, virtualClusterInstance.Name, config, metav1.CreateOptions{}) - if err != nil { - return nil, fmt.Errorf("create direct cluster endpoint token: %w", err) - } - - kubeConfig, err := clientcmd.Load([]byte(directVirtualClusterKubeConfig.Status.KubeConfig)) - if err != nil { - return nil, err - } - - return kubeConfig, nil + return directIngressKubeConfig(req) } // find cluster by clusterRef @@ -260,34 +257,7 @@ func kubeConfigForVirtualClusterInstance( // direct cluster access? if hostCluster.GetAnnotations()[annotations.LoftDirectClusterEndpoint] != "" { - tok := &managementv1.DirectClusterEndpointToken{ - Spec: managementv1.DirectClusterEndpointTokenSpec{ - Scope: scope, - TTL: ttl, - }, - } - directClusterEndpointToken, err := managementClient.Loft(). - ManagementV1(). - DirectClusterEndpointTokens(). - Create(ctx, tok, metav1.CreateOptions{}) - if err != nil { - return nil, fmt.Errorf("create direct cluster endpoint token: %w", err) - } - - directClusterEndpoint := hostCluster.GetAnnotations()[annotations.LoftDirectClusterEndpoint] - host := fmt.Sprintf( - "https://%s/kubernetes/project/%s/virtualcluster/%s", - directClusterEndpoint, - projectName, - virtualClusterInstance.Name, - ) - - return newKubeConfig( - host, - directClusterEndpointToken.Status.Token, - virtualClusterInstance.Spec.ClusterRef.Namespace, - true, - ), nil + return directClusterEndpointKubeConfig(req, hostCluster) } // access through management cluster + access key @@ -296,7 +266,7 @@ func kubeConfigForVirtualClusterInstance( AccessKeySpec: storagev1.AccessKeySpec{ User: baseClient.Self().Status.User.Name, Scope: scope, - TTL: ttl, + TTL: req.ttl, DisplayName: fmt.Sprintf( "Kube Config for Virtual Cluster %s/%s", virtualClusterInstance.Namespace, @@ -331,6 +301,73 @@ func kubeConfigForVirtualClusterInstance( ), nil } +type vClusterKubeConfigRequest struct { + ctx context.Context + managementClient kube.Interface + namespace string + projectName string + scope *storagev1.AccessKeyScope + ttl int64 + instance *managementv1.VirtualClusterInstance +} + +func directIngressKubeConfig(req vClusterKubeConfigRequest) (*clientcmdapi.Config, error) { + certTTL := int32(req.ttl) // #nosec G115 -- ttl from fixed 90-day constant, no overflow + config := &managementv1.VirtualClusterInstanceKubeConfig{ + Spec: managementv1.VirtualClusterInstanceKubeConfigSpec{ + CertificateTTL: &certTTL, + }, + } + directVirtualClusterKubeConfig, err := req.managementClient.Loft(). + ManagementV1(). + VirtualClusterInstances(req.namespace). + GetKubeConfig(req.ctx, req.instance.Name, config, metav1.CreateOptions{}) + if err != nil { + return nil, fmt.Errorf("get virtual cluster kube config: %w", err) + } + + kubeConfig, err := clientcmd.Load([]byte(directVirtualClusterKubeConfig.Status.KubeConfig)) + if err != nil { + return nil, err + } + + return kubeConfig, nil +} + +func directClusterEndpointKubeConfig( + req vClusterKubeConfigRequest, + hostCluster managementv1.Cluster, +) (*clientcmdapi.Config, error) { + tok := &managementv1.DirectClusterEndpointToken{ + Spec: managementv1.DirectClusterEndpointTokenSpec{ + Scope: req.scope, + TTL: req.ttl, + }, + } + directClusterEndpointToken, err := req.managementClient.Loft(). + ManagementV1(). + DirectClusterEndpointTokens(). + Create(req.ctx, tok, metav1.CreateOptions{}) + if err != nil { + return nil, fmt.Errorf("create direct cluster endpoint token: %w", err) + } + + directClusterEndpoint := hostCluster.GetAnnotations()[annotations.LoftDirectClusterEndpoint] + host := fmt.Sprintf( + "https://%s/kubernetes/project/%s/virtualcluster/%s", + directClusterEndpoint, + req.projectName, + req.instance.Name, + ) + + return newKubeConfig( + host, + directClusterEndpointToken.Status.Token, + req.instance.Spec.ClusterRef.Namespace, + true, + ), nil +} + func findHostCluster( ctx context.Context, baseClient client.Client, diff --git a/pkg/platform/owner.go b/pkg/platform/owner.go index 13edb3bb8..d6655a7ba 100644 --- a/pkg/platform/owner.go +++ b/pkg/platform/owner.go @@ -13,25 +13,31 @@ func IsOwner(self *managementv1.Self, userOrTeam *storagev1.UserOrTeam) bool { return false } - if self.Status.User != nil { - // is user owner? - if self.Status.User.Name == userOrTeam.User { - return true - } - - // is user in owning team? - for _, team := range self.Status.User.Teams { - if team.Name == userOrTeam.Team { - return true - } - } + if userIsOwner(self.Status.User, userOrTeam) { + return true } // is user owning team? - if self.Status.Team != nil && self.Status.Team.Name == userOrTeam.Team { + return self.Status.Team != nil && self.Status.Team.Name == userOrTeam.Team +} + +func userIsOwner(user *managementv1.UserInfo, userOrTeam *storagev1.UserOrTeam) bool { + if user == nil { + return false + } + + // is user owner? + if user.Name == userOrTeam.User { return true } + // is user in owning team? + for _, team := range user.Teams { + if team.Name == userOrTeam.Team { + return true + } + } + return false } diff --git a/pkg/platform/parameters/parameters.go b/pkg/platform/parameters/parameters.go index 1dc474715..66c9dd581 100644 --- a/pkg/platform/parameters/parameters.go +++ b/pkg/platform/parameters/parameters.go @@ -12,151 +12,181 @@ import ( func VerifyValue(value string, parameter storagev1.AppParameter) (any, error) { switch parameter.Type { - case "": - fallthrough - case "password": - fallthrough - case "string": - fallthrough - case "multiline": - if parameter.DefaultValue != "" && value == "" { - value = parameter.DefaultValue - } + case "", "password", "string", "multiline": + return verifyStringValue(value, parameter) + case "boolean": + return verifyBooleanValue(value, parameter) + case "number": + return verifyNumberValue(value, parameter) + } - if parameter.Required && value == "" { - return nil, fmt.Errorf( - "parameter %s (%s) is required", - parameter.Label, - parameter.Variable, - ) - } - if slices.Contains(parameter.Options, value) { - return value, nil - } - if parameter.Validation != "" { - regEx, err := regexp.Compile(parameter.Validation) - if err != nil { - return nil, fmt.Errorf("compile validation regex %s: %w", parameter.Validation, err) - } - - if !regEx.MatchString(value) { - return nil, fmt.Errorf( - "parameter %s (%s) needs to match regex %s", - parameter.Label, - parameter.Variable, - parameter.Validation, - ) - } - } - if parameter.Invalidation != "" { - regEx, err := regexp.Compile(parameter.Invalidation) - if err != nil { - return nil, fmt.Errorf( - "compile invalidation regex %s: %w", - parameter.Invalidation, - err, - ) - } - - if regEx.MatchString(value) { - return nil, fmt.Errorf( - "parameter %s (%s) cannot match regex %s", - parameter.Label, - parameter.Variable, - parameter.Invalidation, - ) - } - } + return nil, fmt.Errorf( + "unrecognized type %s for parameter %s (%s)", + parameter.Type, + parameter.Label, + parameter.Variable, + ) +} + +func requiredError(parameter storagev1.AppParameter) error { + return fmt.Errorf( + "parameter %s (%s) is required", + parameter.Label, + parameter.Variable, + ) +} + +func verifyStringValue(value string, parameter storagev1.AppParameter) (any, error) { + if parameter.DefaultValue != "" && value == "" { + value = parameter.DefaultValue + } + if parameter.Required && value == "" { + return nil, requiredError(parameter) + } + if slices.Contains(parameter.Options, value) { return value, nil - case "boolean": - if parameter.DefaultValue != "" && value == "" { - boolValue, err := strconv.ParseBool(parameter.DefaultValue) - if err != nil { - return nil, fmt.Errorf( - "parse default value for parameter %s (%s): %w", - parameter.Label, - parameter.Variable, - err, - ) - } - - return boolValue, nil - } - if parameter.Required && value == "" { - return nil, fmt.Errorf( - "parameter %s (%s) is required", - parameter.Label, - parameter.Variable, - ) - } + } + if err := checkValidationRegex(value, parameter); err != nil { + return nil, err + } + if err := checkInvalidationRegex(value, parameter); err != nil { + return nil, err + } + + return value, nil +} + +func checkValidationRegex(value string, parameter storagev1.AppParameter) error { + if parameter.Validation == "" { + return nil + } + + regEx, err := regexp.Compile(parameter.Validation) + if err != nil { + return fmt.Errorf("compile validation regex %s: %w", parameter.Validation, err) + } + + if !regEx.MatchString(value) { + return fmt.Errorf( + "parameter %s (%s) needs to match regex %s", + parameter.Label, + parameter.Variable, + parameter.Validation, + ) + } + + return nil +} + +func checkInvalidationRegex(value string, parameter storagev1.AppParameter) error { + if parameter.Invalidation == "" { + return nil + } + + regEx, err := regexp.Compile(parameter.Invalidation) + if err != nil { + return fmt.Errorf( + "compile invalidation regex %s: %w", + parameter.Invalidation, + err, + ) + } + + if regEx.MatchString(value) { + return fmt.Errorf( + "parameter %s (%s) cannot match regex %s", + parameter.Label, + parameter.Variable, + parameter.Invalidation, + ) + } + + return nil +} - boolValue, err := strconv.ParseBool(value) +func verifyBooleanValue(value string, parameter storagev1.AppParameter) (any, error) { + if parameter.DefaultValue != "" && value == "" { + boolValue, err := strconv.ParseBool(parameter.DefaultValue) if err != nil { return nil, fmt.Errorf( - "parse value for parameter %s (%s): %w", + "parse default value for parameter %s (%s): %w", parameter.Label, parameter.Variable, err, ) } + return boolValue, nil - case "number": - if parameter.DefaultValue != "" && value == "" { - intValue, err := strconv.Atoi(parameter.DefaultValue) - if err != nil { - return nil, fmt.Errorf( - "parse default value for parameter %s (%s): %w", - parameter.Label, - parameter.Variable, - err, - ) - } - - return intValue, nil - } - if parameter.Required && value == "" { - return nil, fmt.Errorf( - "parameter %s (%s) is required", - parameter.Label, - parameter.Variable, - ) - } - num, err := strconv.Atoi(value) + } + if parameter.Required && value == "" { + return nil, requiredError(parameter) + } + + boolValue, err := strconv.ParseBool(value) + if err != nil { + return nil, fmt.Errorf( + "parse value for parameter %s (%s): %w", + parameter.Label, + parameter.Variable, + err, + ) + } + return boolValue, nil +} + +func verifyNumberValue(value string, parameter storagev1.AppParameter) (any, error) { + if parameter.DefaultValue != "" && value == "" { + intValue, err := strconv.Atoi(parameter.DefaultValue) if err != nil { return nil, fmt.Errorf( - "parse value for parameter %s (%s): %w", + "parse default value for parameter %s (%s): %w", parameter.Label, parameter.Variable, err, ) } - if parameter.Min != nil && num < *parameter.Min { - return nil, fmt.Errorf( - "parameter %s (%s) cannot be smaller than %d", - parameter.Label, - parameter.Variable, - *parameter.Min, - ) - } - if parameter.Max != nil && num > *parameter.Max { - return nil, fmt.Errorf( - "parameter %s (%s) cannot be greater than %d", - parameter.Label, - parameter.Variable, - *parameter.Max, - ) - } - return num, nil + return intValue, nil + } + if parameter.Required && value == "" { + return nil, requiredError(parameter) + } + num, err := strconv.Atoi(value) + if err != nil { + return nil, fmt.Errorf( + "parse value for parameter %s (%s): %w", + parameter.Label, + parameter.Variable, + err, + ) + } + if err := checkNumberBounds(num, parameter); err != nil { + return nil, err } - return nil, fmt.Errorf( - "unrecognized type %s for parameter %s (%s)", - parameter.Type, - parameter.Label, - parameter.Variable, - ) + return num, nil +} + +func checkNumberBounds(num int, parameter storagev1.AppParameter) error { + if parameter.Min != nil && num < *parameter.Min { + return fmt.Errorf( + "parameter %s (%s) cannot be smaller than %d", + parameter.Label, + parameter.Variable, + *parameter.Min, + ) + } + if parameter.Max != nil && num > *parameter.Max { + return fmt.Errorf( + "parameter %s (%s) cannot be greater than %d", + parameter.Label, + parameter.Variable, + *parameter.Max, + ) + } + + return nil } func GetDeepValue(parameters any, path string) any { @@ -167,29 +197,37 @@ func GetDeepValue(parameters any, path string) any { pathSegments := strings.Split(path, ".") switch t := parameters.(type) { case map[string]any: - val, ok := t[pathSegments[0]] - if !ok { - return nil - } else if len(pathSegments) == 1 { - return val - } - - return GetDeepValue(val, strings.Join(pathSegments[1:], ".")) + return getDeepValueFromMap(t, pathSegments) case []any: - index, err := strconv.Atoi(pathSegments[0]) - if err != nil { - return nil - } else if index < 0 || index >= len(t) { - return nil - } + return getDeepValueFromSlice(t, pathSegments) + } - val := t[index] - if len(pathSegments) == 1 { - return val - } + return nil +} - return GetDeepValue(val, strings.Join(pathSegments[1:], ".")) +func getDeepValueFromMap(t map[string]any, pathSegments []string) any { + val, ok := t[pathSegments[0]] + if !ok { + return nil + } else if len(pathSegments) == 1 { + return val } - return nil + return GetDeepValue(val, strings.Join(pathSegments[1:], ".")) +} + +func getDeepValueFromSlice(t []any, pathSegments []string) any { + index, err := strconv.Atoi(pathSegments[0]) + if err != nil { + return nil + } else if index < 0 || index >= len(t) { + return nil + } + + val := t[index] + if len(pathSegments) == 1 { + return val + } + + return GetDeepValue(val, strings.Join(pathSegments[1:], ".")) } diff --git a/pkg/platform/remotecommand/client.go b/pkg/platform/remotecommand/client.go index 2b1bd760d..4eb980258 100644 --- a/pkg/platform/remotecommand/client.go +++ b/pkg/platform/remotecommand/client.go @@ -61,20 +61,28 @@ func ExecuteConn( continue } - switch message.messageType { - case StdoutData: - if _, err := io.Copy(stdout, message.data); err != nil { - log.Debugf("error read stdout: %v", err) - return 1, err - } - case StderrData: - if _, err := io.Copy(stderr, message.data); err != nil { - log.Debugf("error read stderr: %v", err) - return 1, err - } - case ExitCode: - log.Debugf("exit code: %d", message.exitCode) - return int(message.exitCode), nil + exitCode, done, err := writeExecMessage(message, stdout, stderr) + if done { + return exitCode, err } } } + +func writeExecMessage(message *Message, stdout, stderr io.Writer) (int, bool, error) { + switch message.messageType { + case StdoutData: + if _, err := io.Copy(stdout, message.data); err != nil { + log.Debugf("error read stdout: %v", err) + return 1, true, err + } + case StderrData: + if _, err := io.Copy(stderr, message.data); err != nil { + log.Debugf("error read stderr: %v", err) + return 1, true, err + } + case ExitCode: + log.Debugf("exit code: %d", message.exitCode) + return int(message.exitCode), true, nil + } + return 0, false, nil +} diff --git a/pkg/provider/env.go b/pkg/provider/env.go index fde938713..3fe387c0c 100644 --- a/pkg/provider/env.go +++ b/pkg/provider/env.go @@ -67,42 +67,52 @@ func CombineOptions( func ToOptionsWorkspace(workspace *Workspace) map[string]string { retVars := map[string]string{} - if workspace != nil { - if workspace.ID != "" { - retVars[config.EnvProviderWorkspaceID] = workspace.ID - } - if workspace.UID != "" { - retVars[config.EnvProviderWorkspaceUID] = workspace.UID - } - workspaceFolder, _ := GetWorkspaceDir(workspace.Context, workspace.ID) - retVars[config.EnvProviderWorkspaceFolder] = filepath.ToSlash(workspaceFolder) - if workspace.Context != "" { - retVars[config.EnvProviderWorkspaceContext] = workspace.Context - retVars[config.EnvProviderMachineContext] = workspace.Context - } - if workspace.Origin != "" { - retVars[config.EnvProviderWorkspaceOrigin] = filepath.ToSlash(workspace.Origin) - } - if workspace.Picture != "" { - retVars[config.EnvProviderWorkspacePicture] = workspace.Picture - } - retVars[config.EnvProviderWorkspaceSource] = workspace.Source.String() - if workspace.Provider.Name != "" { - retVars[config.EnvProviderWorkspaceProvider] = workspace.Provider.Name - } - if workspace.Machine.ID != "" { - retVars[config.EnvProviderMachineID] = workspace.Machine.ID - machineDir, _ := GetMachineDir(workspace.Context, workspace.Machine.ID) - retVars[config.EnvProviderMachineFolder] = filepath.ToSlash(machineDir) - } - if workspace.Pro != nil && workspace.Pro.Project != "" { - retVars[config.EnvLoftProject] = workspace.Pro.Project - } - maps.Copy(retVars, GetBaseEnvironment(workspace.Context, workspace.Provider.Name)) + if workspace == nil { + return retVars + } + + addWorkspaceIdentityEnv(retVars, workspace) + addWorkspaceMachineEnv(retVars, workspace) + if workspace.Pro != nil && workspace.Pro.Project != "" { + retVars[config.EnvLoftProject] = workspace.Pro.Project } + maps.Copy(retVars, GetBaseEnvironment(workspace.Context, workspace.Provider.Name)) return retVars } +func addWorkspaceIdentityEnv(retVars map[string]string, workspace *Workspace) { + if workspace.ID != "" { + retVars[config.EnvProviderWorkspaceID] = workspace.ID + } + if workspace.UID != "" { + retVars[config.EnvProviderWorkspaceUID] = workspace.UID + } + workspaceFolder, _ := GetWorkspaceDir(workspace.Context, workspace.ID) + retVars[config.EnvProviderWorkspaceFolder] = filepath.ToSlash(workspaceFolder) + if workspace.Context != "" { + retVars[config.EnvProviderWorkspaceContext] = workspace.Context + retVars[config.EnvProviderMachineContext] = workspace.Context + } + if workspace.Origin != "" { + retVars[config.EnvProviderWorkspaceOrigin] = filepath.ToSlash(workspace.Origin) + } + if workspace.Picture != "" { + retVars[config.EnvProviderWorkspacePicture] = workspace.Picture + } + retVars[config.EnvProviderWorkspaceSource] = workspace.Source.String() +} + +func addWorkspaceMachineEnv(retVars map[string]string, workspace *Workspace) { + if workspace.Provider.Name != "" { + retVars[config.EnvProviderWorkspaceProvider] = workspace.Provider.Name + } + if workspace.Machine.ID != "" { + retVars[config.EnvProviderMachineID] = workspace.Machine.ID + machineDir, _ := GetMachineDir(workspace.Context, workspace.Machine.ID) + retVars[config.EnvProviderMachineFolder] = filepath.ToSlash(machineDir) + } +} + func ToOptionsMachine(machine *Machine) map[string]string { retVars := map[string]string{} if machine != nil { diff --git a/pkg/provider/parse.go b/pkg/provider/parse.go index 803606221..f01f9c39e 100644 --- a/pkg/provider/parse.go +++ b/pkg/provider/parse.go @@ -48,80 +48,17 @@ func ParseProvider(reader io.Reader) (*ProviderConfig, error) { } func validate(config *ProviderConfig) error { - // validate name - if config.Name == "" { - return fmt.Errorf("name is missing in provider.yaml") - } - if ProviderNameRegEx.MatchString(config.Name) { - return fmt.Errorf("provider name can only include lowercase letters, numbers or dashes") - } else if len(config.Name) > 32 { - return fmt.Errorf("provider name cannot be longer than 32 characters") + if err := validateProviderName(config.Name); err != nil { + return err } - // validate version - if config.Version != "" { - _, err := semver.Parse(strings.TrimPrefix(config.Version, "v")) - if err != nil { - return fmt.Errorf("parse provider version: %w", err) - } + if err := validateProviderVersion(config.Version); err != nil { + return err } - // validate option names for optionName, optionValue := range config.Options { - if optionNameRegEx.MatchString(optionName) { - return fmt.Errorf( - "provider option %q can only consist of upper case letters, numbers or underscores, "+ - "e.g. MY_OPTION, MY_OTHER_OPTION", - optionName, - ) - } - - // validate option validation - if optionValue.ValidationPattern != "" { - _, err := regexp.Compile(optionValue.ValidationPattern) - if err != nil { - return fmt.Errorf( - "error parsing validation pattern %q for option %q: %w", - optionValue.ValidationPattern, optionName, err, - ) - } - } - - if optionValue.Default != "" && optionValue.Command != "" { - return fmt.Errorf( - "default and command cannot be used together in option %q", - optionName, - ) - } - - if optionValue.Global && optionValue.Cache != "" { - return fmt.Errorf("global and cache cannot be used together in option %q", optionName) - } - - if optionValue.Global && optionValue.Mutable { - return fmt.Errorf( - "global and mutable cannot be used together in option %q", - optionName, - ) - } - - if optionValue.Cache != "" { - _, err := time.ParseDuration(optionValue.Cache) - if err != nil { - return fmt.Errorf("invalid cache value for option %q: %w", optionName, err) - } - } - - if optionValue.Type != "" && !contains(allowedTypes, optionValue.Type) { - return fmt.Errorf( - "type can only be one of in option %q: %v", - optionName, - allowedTypes, - ) - } - - if optionValue.Cache != "" && optionValue.Command == "" { - return fmt.Errorf("cache can only be used with command in option %q", optionName) + if err := validateProviderOption(optionName, optionValue); err != nil { + return err } } @@ -143,6 +80,113 @@ func validate(config *ProviderConfig) error { return nil } +func validateProviderName(name string) error { + if name == "" { + return fmt.Errorf("name is missing in provider.yaml") + } + if ProviderNameRegEx.MatchString(name) { + return fmt.Errorf("provider name can only include lowercase letters, numbers or dashes") + } else if len(name) > 32 { + return fmt.Errorf("provider name cannot be longer than 32 characters") + } + + return nil +} + +func validateProviderVersion(version string) error { + if version == "" { + return nil + } + + _, err := semver.Parse(strings.TrimPrefix(version, "v")) + if err != nil { + return fmt.Errorf("parse provider version: %w", err) + } + + return nil +} + +func validateProviderOption(optionName string, optionValue *types.Option) error { + if optionNameRegEx.MatchString(optionName) { + return fmt.Errorf( + "provider option %q can only consist of upper case letters, numbers or underscores, "+ + "e.g. MY_OPTION, MY_OTHER_OPTION", + optionName, + ) + } + + if err := validateOptionPattern(optionName, optionValue); err != nil { + return err + } + + if err := validateOptionExclusivity(optionName, optionValue); err != nil { + return err + } + + return validateOptionCacheAndType(optionName, optionValue) +} + +func validateOptionPattern(optionName string, optionValue *types.Option) error { + if optionValue.ValidationPattern == "" { + return nil + } + + _, err := regexp.Compile(optionValue.ValidationPattern) + if err != nil { + return fmt.Errorf( + "error parsing validation pattern %q for option %q: %w", + optionValue.ValidationPattern, optionName, err, + ) + } + + return nil +} + +func validateOptionExclusivity(optionName string, optionValue *types.Option) error { + if optionValue.Default != "" && optionValue.Command != "" { + return fmt.Errorf( + "default and command cannot be used together in option %q", + optionName, + ) + } + + if optionValue.Global && optionValue.Cache != "" { + return fmt.Errorf("global and cache cannot be used together in option %q", optionName) + } + + if optionValue.Global && optionValue.Mutable { + return fmt.Errorf( + "global and mutable cannot be used together in option %q", + optionName, + ) + } + + return nil +} + +func validateOptionCacheAndType(optionName string, optionValue *types.Option) error { + if optionValue.Cache != "" { + _, err := time.ParseDuration(optionValue.Cache) + if err != nil { + return fmt.Errorf("invalid cache value for option %q: %w", optionName, err) + } + } + + if optionValue.Type != "" && !contains(allowedTypes, optionValue.Type) { + return fmt.Errorf( + "type can only be one of in option %q: %v", + optionName, + allowedTypes, + ) + } + + if optionValue.Cache != "" && optionValue.Command == "" { + return fmt.Errorf("cache can only be used with command in option %q", optionName) + } + + return nil +} + func validateProviderType(config *ProviderConfig) error { if config.IsProxyProvider() { return validateProxyProvider(config) @@ -330,30 +374,8 @@ func validateBinaries(prefix string, binaries map[string][]*ProviderBinary) erro } for _, binary := range binaryArr { - if binary.OS != "linux" && binary.OS != "darwin" && binary.OS != "windows" { - return fmt.Errorf( - "unsupported binary operating system %q, must be 'linux', 'darwin' or 'windows'", - binary.OS, - ) - } - if binary.Path == "" { - return fmt.Errorf( - "%s.%s.path required binary path, cannot be empty", - prefix, - binaryName, - ) - } - if binary.ArchivePath == "" && - (strings.HasSuffix(binary.Path, ".gz") || strings.HasSuffix(binary.Path, ".tar") || - strings.HasSuffix(binary.Path, ".tgz") || strings.HasSuffix(binary.Path, ".zip")) { - return fmt.Errorf( - "%s.%s.archivePath required because binary path is an archive", - prefix, - binaryName, - ) - } - if binary.Arch == "" { - return fmt.Errorf("%s.%s.arch required, cannot be empty", prefix, binaryName) + if err := validateBinary(prefix, binaryName, binary); err != nil { + return err } } } @@ -361,6 +383,39 @@ func validateBinaries(prefix string, binaries map[string][]*ProviderBinary) erro return nil } +func validateBinary(prefix, binaryName string, binary *ProviderBinary) error { + if binary.OS != "linux" && binary.OS != "darwin" && binary.OS != "windows" { + return fmt.Errorf( + "unsupported binary operating system %q, must be 'linux', 'darwin' or 'windows'", + binary.OS, + ) + } + if binary.Path == "" { + return fmt.Errorf( + "%s.%s.path required binary path, cannot be empty", + prefix, + binaryName, + ) + } + if binary.ArchivePath == "" && pathIsArchive(binary.Path) { + return fmt.Errorf( + "%s.%s.archivePath required because binary path is an archive", + prefix, + binaryName, + ) + } + if binary.Arch == "" { + return fmt.Errorf("%s.%s.arch required, cannot be empty", prefix, binaryName) + } + + return nil +} + +func pathIsArchive(path string) bool { + return strings.HasSuffix(path, ".gz") || strings.HasSuffix(path, ".tar") || + strings.HasSuffix(path, ".tgz") || strings.HasSuffix(path, ".zip") +} + func ParseOptions(options []string) (map[string]string, error) { retMap := map[string]string{} for _, option := range options { diff --git a/pkg/shell/shell.go b/pkg/shell/shell.go index 6c81071e1..0daf538bc 100644 --- a/pkg/shell/shell.go +++ b/pkg/shell/shell.go @@ -46,13 +46,47 @@ func RunEmulatedShell( return err } - // create options + // Create shell runner + r, err := interp.New(buildRunnerOptions(runnerParams{ + stdin: stdin, + stdout: stdout, + stderr: stderr, + env: env, + dir: dir, + })...) + if err != nil { + return fmt.Errorf("create shell runner: %w", err) + } + + // Run command + err = r.Run(ctx, parsed) + if err != nil { + var exitStatus interp.ExitStatus + if errors.As(err, &exitStatus) && exitStatus == 0 { + return nil + } + + return err + } + + return nil +} + +type runnerParams struct { + stdin io.Reader + stdout io.Writer + stderr io.Writer + env []string + dir string +} + +func buildRunnerOptions(p runnerParams) []interp.RunnerOption { defaultOpenHandler := interp.DefaultOpenHandler() defaultExecHandler := interp.DefaultExecHandler(2 * time.Second) - options := []interp.RunnerOption{ - interp.StdIO(stdin, stdout, stderr), - interp.Env(expand.ListEnviron(env...)), - interp.Dir(dir), + return []interp.RunnerOption{ + interp.StdIO(p.stdin, p.stdout, p.stderr), + interp.Env(expand.ListEnviron(p.env...)), + interp.Dir(p.dir), interp.ExecHandlers(func(next interp.ExecHandlerFunc) interp.ExecHandlerFunc { return func(ctx context.Context, args []string) error { return defaultExecHandler(ctx, args) @@ -79,25 +113,6 @@ func RunEmulatedShell( }, ), } - - // Create shell runner - r, err := interp.New(options...) - if err != nil { - return fmt.Errorf("create shell runner: %w", err) - } - - // Run command - err = r.Run(ctx, parsed) - if err != nil { - var exitStatus interp.ExitStatus - if errors.As(err, &exitStatus) && exitStatus == 0 { - return nil - } - - return err - } - - return nil } var _ io.ReadWriteCloser = devNull{} diff --git a/pkg/ssh/config.go b/pkg/ssh/config.go index 155e1bf67..ab11e8884 100644 --- a/pkg/ssh/config.go +++ b/pkg/ssh/config.go @@ -426,6 +426,24 @@ func transformHostSection(path, host string, transform func(line string) string) defer func() { _ = f.Close() }() } + newLines, scanErr := scanHostSection(reader, host, transform) + if scanErr != nil { + return "", fmt.Errorf("parse ssh config: %w", scanErr) + } + + // remove residual empty line at start file + if len(newLines) > 0 && newLines[0] == "" { + newLines = newLines[1:] + } + + return strings.Join(newLines, "\n"), nil +} + +func scanHostSection( + reader io.Reader, + host string, + transform func(line string) string, +) ([]string, error) { configScanner := scanner.NewScanner(reader) newLines := []string{} inSection := false @@ -447,14 +465,6 @@ func transformHostSection(path, host string, transform func(line string) string) } } } - if configScanner.Err() != nil { - return "", fmt.Errorf("parse ssh config: %w", err) - } - - // remove residual empty line at start file - if len(newLines) > 0 && newLines[0] == "" { - newLines = newLines[1:] - } - return strings.Join(newLines, "\n"), nil + return newLines, configScanner.Err() } diff --git a/pkg/ssh/server/ssh.go b/pkg/ssh/server/ssh.go index d8c00ecb3..5e55fe6a1 100644 --- a/pkg/ssh/server/ssh.go +++ b/pkg/ssh/server/ssh.go @@ -191,18 +191,7 @@ func NewServer( log.Debugf("attempt to bind %s:%d - %s", host, port, "granted") return true }, - ReverseUnixForwardingCallback: func(ctx ssh.Context, socketPath string) bool { - log.Debugf("attempt to bind socket %s", socketPath) - - _, err := os.Stat(socketPath) - if err == nil { - log.Debugf("%s already exists, removing", socketPath) - - _ = os.Remove(socketPath) - } - - return true - }, + ReverseUnixForwardingCallback: reverseUnixForwardingCallback, ChannelHandlers: map[string]ssh.ChannelHandler{ "direct-tcpip": ssh.DirectTCPIPHandler, "direct-streamlocal@openssh.com": ssh.DirectStreamLocalHandler, @@ -223,16 +212,7 @@ func NewServer( } if len(keys) > 0 { - server.sshServer.PublicKeyHandler = func(ctx ssh.Context, key ssh.PublicKey) bool { - for _, k := range keys { - if ssh.KeysEqual(k, key) { - return true - } - } - - log.Debugf("Declined public key") - return false - } + server.sshServer.PublicKeyHandler = makePublicKeyHandler(keys) } if len(hostKey) > 0 { @@ -248,6 +228,32 @@ func NewServer( return server, nil } +func reverseUnixForwardingCallback(_ ssh.Context, socketPath string) bool { + log.Debugf("attempt to bind socket %s", socketPath) + + _, err := os.Stat(socketPath) + if err == nil { + log.Debugf("%s already exists, removing", socketPath) + + _ = os.Remove(socketPath) + } + + return true +} + +func makePublicKeyHandler(keys []ssh.PublicKey) func(ctx ssh.Context, key ssh.PublicKey) bool { + return func(_ ssh.Context, key ssh.PublicKey) bool { + for _, k := range keys { + if ssh.KeysEqual(k, key) { + return true + } + } + + log.Debugf("Declined public key") + return false + } +} + // cleanupAgentOnConnClosing tears down the per-connection agent state when // HandleConn observes the inbound channels stream close. Runs synchronously // in HandleConn's defer chain before sshConn.Wait() — so it fires reliably @@ -306,47 +312,32 @@ func (intent *connAgentIntent) ensureState() (*connAgentState, error) { return state, err } +func (s *server) Serve(listener net.Listener) error { + return s.sshServer.Serve(listener) +} + +func (s *server) ListenAndServe() error { + log.Debugf("Start ssh server on %s", s.sshServer.Addr) + return s.sshServer.ListenAndServe() +} + +func (s *server) Shutdown(ctx context.Context) error { + return s.sshServer.Shutdown(ctx) +} + func (s *server) handler(sess ssh.Session) { var err error ptyReq, winCh, isPty := sess.Pty() cmd := s.getCommand(sess, isPty) if ssh.AgentRequested(sess) { - if s.reuseSock != "" { - // openvscode backhaul / explicit shared-socket mode: keep the - // existing per-session listener behavior. - l, tmpDir, err := setupAgentListener(s.reuseSock) - if err != nil { - exitWithError(sess, err) - return - } - defer func() { _ = l.Close() }() - defer func() { _ = os.RemoveAll(tmpDir) }() - - go ssh.ForwardAgentConnections(l, sess) - - cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", "SSH_AUTH_SOCK", l.Addr().String())) - } else if intent, ok := sess.Context().Value(ctxKeyConnAgent).(*connAgentIntent); ok && intent != nil { - // Common interactive case: lazily allocate the connection-scoped - // listener on first request, then reuse it for every subsequent - // session on the same connection. ForwardAgentConnections needs an - // ssh.Session to open the auth-agent channel, so it is bound to - // the first session that requests agent forwarding. - state, sErr := intent.ensureState() - if sErr != nil || state == nil { - log.Errorf("ssh agent forwarding setup failed (connID=%s): %v", intent.connID, sErr) - _, _ = fmt.Fprintf( - sess.Stderr(), - "warning: ssh agent forwarding unavailable: %v\n", - sErr, - ) - exitWithError(sess, sErr) - return - } - state.startForwarding(sess) - cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", "SSH_AUTH_SOCK", state.sockPath())) - } else { - log.Debugf("agent requested but no connection-scoped agent intent available") + cleanup, exit, aErr := s.configureAgent(sess, cmd) + if cleanup != nil { + defer cleanup() + } + if exit { + exitWithError(sess, aErr) + return } } @@ -390,7 +381,9 @@ func (s *server) getCommand(sess ssh.Session, isPty bool) *exec.Cmd { args = append(args, "-c", sess.RawCommand()) } - cmd = exec.Command("su", args...) + cmd = exec.Command( + "su", + args...) // #nosec G204 -- args built from session request in the ssh server } else { args := []string{} args = append(args, s.shell[1:]...) @@ -399,10 +392,14 @@ func (s *server) getCommand(sess ssh.Session, isPty bool) *exec.Cmd { } if len(sess.RawCommand()) == 0 { - cmd = exec.Command(s.shell[0], args...) + cmd = exec.Command( + s.shell[0], + args...) // #nosec G204 -- shell configured by the ssh server, not user input } else { args = append(args, "-c", sess.RawCommand()) - cmd = exec.Command(s.shell[0], args...) + cmd = exec.Command( + s.shell[0], + args...) // #nosec G204 -- shell configured by the ssh server, not user input } } @@ -412,17 +409,48 @@ func (s *server) getCommand(sess ssh.Session, isPty bool) *exec.Cmd { return cmd } -func (s *server) Serve(listener net.Listener) error { - return s.sshServer.Serve(listener) -} +func (s *server) configureAgent(sess ssh.Session, cmd *exec.Cmd) (func(), bool, error) { + if s.reuseSock != "" { + // openvscode backhaul / explicit shared-socket mode: keep the + // existing per-session listener behavior. + l, tmpDir, err := setupAgentListener(s.reuseSock) + if err != nil { + return nil, true, err + } -func (s *server) ListenAndServe() error { - log.Debugf("Start ssh server on %s", s.sshServer.Addr) - return s.sshServer.ListenAndServe() -} + go ssh.ForwardAgentConnections(l, sess) -func (s *server) Shutdown(ctx context.Context) error { - return s.sshServer.Shutdown(ctx) + cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", "SSH_AUTH_SOCK", l.Addr().String())) + return func() { + _ = l.Close() + _ = os.RemoveAll(tmpDir) + }, false, nil + } + + intent, _ := sess.Context().Value(ctxKeyConnAgent).(*connAgentIntent) + if intent == nil { + log.Debugf("agent requested but no connection-scoped agent intent available") + return nil, false, nil + } + + // Common interactive case: lazily allocate the connection-scoped + // listener on first request, then reuse it for every subsequent + // session on the same connection. ForwardAgentConnections needs an + // ssh.Session to open the auth-agent channel, so it is bound to + // the first session that requests agent forwarding. + state, sErr := intent.ensureState() + if sErr != nil || state == nil { + log.Errorf("ssh agent forwarding setup failed (connID=%s): %v", intent.connID, sErr) + _, _ = fmt.Fprintf( + sess.Stderr(), + "warning: ssh agent forwarding unavailable: %v\n", + sErr, + ) + return nil, true, sErr + } + state.startForwarding(sess) + cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", "SSH_AUTH_SOCK", state.sockPath())) + return nil, false, nil } // connCallback is invoked once per inbound SSH connection. Outside the diff --git a/pkg/telemetry/collect.go b/pkg/telemetry/collect.go index 8c305c69e..142030af4 100644 --- a/pkg/telemetry/collect.go +++ b/pkg/telemetry/collect.go @@ -136,30 +136,18 @@ func (d *cliCollector) RecordCLI(err error) { } timezone, _ := time.Now().Zone() - eventProperties := map[string]any{ - "command": cmd, - "version": version.GetVersion(), - "desktop": isUI, - "is_ci": isCI, - "is_interactive": isInteractive, - } - if d.client != nil { - eventProperties["provider"] = d.client.Provider() - - if d.client.WorkspaceConfig() != nil { - eventProperties["source_type"] = d.client.WorkspaceConfig().Source.Type() - eventProperties["ide"] = d.client.WorkspaceConfig().IDE.Name - } - } + eventProperties := d.buildEventProperties(eventPropertiesParams{ + cmd: cmd, + isUI: isUI, + isCI: isCI, + isInteractive: isInteractive, + err: err, + }) userProperties := map[string]any{ "os_name": runtime.GOOS, "os_arch": runtime.GOARCH, "timezone": timezone, } - // Raw err.Error() strings can leak paths, hostnames, tokens. - if err != nil { - eventProperties["error_code"] = string(clierr.Classify(err).Code) - } eventType := config.BinaryName + "_cli" if os.Getenv(config.EnvProRunner) == config.BoolTrue { @@ -169,6 +157,14 @@ func (d *cliCollector) RecordCLI(err error) { d.recordEvent(eventType, eventProperties, userProperties) } +type eventPropertiesParams struct { + cmd string + isUI bool + isCI bool + isInteractive bool + err error +} + func (d *cliCollector) RecordWorkspaceGauge(count int) { timezone, _ := time.Now().Zone() d.recordEvent( @@ -186,6 +182,29 @@ func (d *cliCollector) RecordWorkspaceGauge(count int) { ) } +func (d *cliCollector) buildEventProperties(p eventPropertiesParams) map[string]any { + eventProperties := map[string]any{ + "command": p.cmd, + "version": version.GetVersion(), + "desktop": p.isUI, + "is_ci": p.isCI, + "is_interactive": p.isInteractive, + } + if d.client != nil { + eventProperties["provider"] = d.client.Provider() + + if workspaceConfig := d.client.WorkspaceConfig(); workspaceConfig != nil { + eventProperties["source_type"] = workspaceConfig.Source.Type() + eventProperties["ide"] = workspaceConfig.IDE.Name + } + } + // Raw err.Error() strings can leak paths, hostnames, tokens. + if p.err != nil { + eventProperties["error_code"] = string(clierr.Classify(p.err).Code) + } + return eventProperties +} + func (d *cliCollector) recordEvent( eventType string, eventProperties, userProperties map[string]any, diff --git a/pkg/ts/workspace_server.go b/pkg/ts/workspace_server.go index 54a9ab04f..469c8826b 100644 --- a/pkg/ts/workspace_server.go +++ b/pkg/ts/workspace_server.go @@ -236,37 +236,28 @@ func (s *WorkspaceServer) startListeners( // add all listeners to the list s.listeners = append(s.listeners, sshListener, wsListener, runnerProxyListener) - // Setup HTTP handler for git credentials + // Setup HTTP handler for git and docker credentials on the runner proxy. go func() { mux := http.NewServeMux() transport := &http.Transport{DialContext: s.tsServer.Dial} mux.HandleFunc("/git-credentials", func(w http.ResponseWriter, r *http.Request) { s.gitCredentialsHandler(w, r, lc, transport, projectName, workspaceName) }) - if err := http.Serve(runnerProxyListener, mux); err != nil && err != http.ErrServerClosed { - log.Errorf("HTTP runner proxy server error: %v", err) - } - }() - - // Setup HTTP handler for docker credentials. - go func() { - mux := http.NewServeMux() - transport := &http.Transport{DialContext: s.tsServer.Dial} mux.HandleFunc("/docker-credentials", func(w http.ResponseWriter, r *http.Request) { s.dockerCredentialsHandler(w, r, lc, transport, projectName, workspaceName) }) - if err := http.Serve(runnerProxyListener, mux); err != nil && err != http.ErrServerClosed { - log.Errorf("HTTP runner proxy server error: %v", err) - } + serveMux(runnerProxyListener, mux, "HTTP runner proxy server error: %v") }() // Setup HTTP handler for port forwarding. go func() { mux := http.NewServeMux() mux.HandleFunc("/portforward", s.httpPortForwardHandler) - if err := http.Serve(wsListener, mux); err != nil && err != http.ErrServerClosed { - log.Errorf("HTTP server error on TS port %s: %v", TSPortForwardPort, err) - } + serveMux( + wsListener, + mux, + fmt.Sprintf("HTTP server error on TS port %s: %%v", TSPortForwardPort), + ) }() // Start handling SSH connections. @@ -275,6 +266,13 @@ func (s *WorkspaceServer) startListeners( return nil } +func serveMux(listener net.Listener, mux *http.ServeMux, errFormat string) { + // #nosec G114 -- internal unix-socket/TSNet listener, not exposed to untrusted networks + if err := http.Serve(listener, mux); err != nil && err != http.ErrServerClosed { + log.Errorf(errFormat, err) + } +} + // createListener creates a raw listener and wraps it with connection tracking. func (s *WorkspaceServer) createListener(addr string) (net.Listener, error) { l, err := s.tsServer.Listen("tcp", addr) diff --git a/pkg/tunnel/browser.go b/pkg/tunnel/browser.go index ef80f5130..7594f158a 100644 --- a/pkg/tunnel/browser.go +++ b/pkg/tunnel/browser.go @@ -153,32 +153,6 @@ func SetupBackhaul( writer := log.Writer(log.LevelInfo) defer func() { _ = writer.Close() }() - buildCmd := func() *exec.Cmd { - //nolint:gosec // execPath is the current binary, arguments are controlled - cmd := exec.CommandContext(ctx, - execPath, - "workspace", - "ssh", - names.FlagTrue(names.AgentForwarding), - names.FlagValue(names.ReuseSSHAuthSock, authSockID), - names.FlagFalse(names.StartServices), - names.Flag(names.User), - remoteUser, - names.Flag(names.Context), - client.Context(), - client.Workspace(), - names.FlagValue(names.LogOutput, "raw"), - names.Flag(names.Command), - "while true; do sleep 6000000; done", // sleep infinity is not available on all systems - ) - if log.DebugEnabled() { - cmd.Args = append(cmd.Args, names.Flag(names.Debug)) - } - cmd.Stdout = writer - cmd.Stderr = writer - return cmd - } - // 5 steps × 200ms ≈ 1s covers the workspace.json atomic-rename window // observed during a concurrent `agent workspace up` rewrite. backoff := wait.Backoff{ @@ -189,7 +163,13 @@ func SetupBackhaul( var lastErr error err = wait.ExponentialBackoffWithContext(ctx, backoff, func(_ context.Context) (bool, error) { - cmd := buildCmd() + cmd := buildBackhaulCmd(ctx, backhaulCmdParams{ + execPath: execPath, + remoteUser: remoteUser, + client: client, + authSockID: authSockID, + writer: writer, + }) lastErr = cmd.Run() if lastErr == nil { return true, nil @@ -203,6 +183,44 @@ func SetupBackhaul( log.Infof("Done setting up backhaul") return nil } + return interpretBackhaulResult(err, lastErr) +} + +type backhaulCmdParams struct { + execPath string + remoteUser string + client client2.BaseWorkspaceClient + authSockID string + writer io.Writer +} + +func buildBackhaulCmd(ctx context.Context, p backhaulCmdParams) *exec.Cmd { + //nolint:gosec // execPath is the current binary, arguments are controlled + cmd := exec.CommandContext(ctx, + p.execPath, + "workspace", + "ssh", + names.FlagTrue(names.AgentForwarding), + names.FlagValue(names.ReuseSSHAuthSock, p.authSockID), + names.FlagFalse(names.StartServices), + names.Flag(names.User), + p.remoteUser, + names.Flag(names.Context), + p.client.Context(), + p.client.Workspace(), + names.FlagValue(names.LogOutput, "raw"), + names.Flag(names.Command), + "while true; do sleep 6000000; done", // sleep infinity is not available on all systems + ) + if log.DebugEnabled() { + cmd.Args = append(cmd.Args, names.Flag(names.Debug)) + } + cmd.Stdout = p.writer + cmd.Stderr = p.writer + return cmd +} + +func interpretBackhaulResult(err, lastErr error) error { if wait.Interrupted(err) { // Either retries exhausted or ctx cancelled; surface the underlying // subprocess error if one is available, else the wait error. diff --git a/pkg/tunnel/forwarder.go b/pkg/tunnel/forwarder.go index 54c538b45..1f735f32b 100644 --- a/pkg/tunnel/forwarder.go +++ b/pkg/tunnel/forwarder.go @@ -62,42 +62,16 @@ func (f *forwarder) Forward(port string, _ netstat.PortForwardAttribute) error { } localAddr := "localhost:" + port - if attr.RequireLocalPort { - if ok, _ := portpkg.IsAvailable(localAddr); !ok { - log.Warnf("Port %s required but unavailable, skipping forward", port) - return fmt.Errorf("required local port %s unavailable", port) - } + if err := ensureLocalPortAvailable(port, localAddr, attr); err != nil { + return err } - if attr.ElevateIfNeeded { - portNum, _ := strconv.Atoi(port) - if portNum < 1024 { - if runtime.GOOS == "linux" { - log.Warnf( - "Port %s requires elevation (elevateIfNeeded=true); privileged port binding not supported in tunnel mode", - port, - ) - } else { - log.Warnf("Port %s: elevateIfNeeded is only applicable on Linux", port) - } - } - } + warnIfElevationNeeded(port, attr) cancelCtx, cancel := context.WithCancel(context.Background()) f.portMap[port] = cancel - parts := []string{} - if attr.Label != "" { - parts = append(parts, attr.Label) - } - if attr.Protocol != "" { - parts = append(parts, attr.Protocol) - } - if len(parts) > 0 { - log.Infof("Start port-forwarding on port %s (%s)", port, strings.Join(parts, ", ")) - } else { - log.Infof("Start port-forwarding on port %s", port) - } + logStartForwarding(port, attr) go func(port string) { network := "tcp" @@ -118,6 +92,50 @@ func (f *forwarder) Forward(port string, _ netstat.PortForwardAttribute) error { return nil } +func ensureLocalPortAvailable(port, localAddr string, attr config2.PortAttribute) error { + if !attr.RequireLocalPort { + return nil + } + if ok, _ := portpkg.IsAvailable(localAddr); !ok { + log.Warnf("Port %s required but unavailable, skipping forward", port) + return fmt.Errorf("required local port %s unavailable", port) + } + return nil +} + +func warnIfElevationNeeded(port string, attr config2.PortAttribute) { + if !attr.ElevateIfNeeded { + return + } + portNum, _ := strconv.Atoi(port) + if portNum >= 1024 { + return + } + if runtime.GOOS == "linux" { + log.Warnf( + "Port %s requires elevation (elevateIfNeeded=true); privileged port binding not supported in tunnel mode", + port, + ) + } else { + log.Warnf("Port %s: elevateIfNeeded is only applicable on Linux", port) + } +} + +func logStartForwarding(port string, attr config2.PortAttribute) { + parts := []string{} + if attr.Label != "" { + parts = append(parts, attr.Label) + } + if attr.Protocol != "" { + parts = append(parts, attr.Protocol) + } + if len(parts) > 0 { + log.Infof("Start port-forwarding on port %s (%s)", port, strings.Join(parts, ", ")) + } else { + log.Infof("Start port-forwarding on port %s", port) + } +} + // StopForward stops the port forwarding for the given port. func (f *forwarder) StopForward(port string) error { f.Lock() diff --git a/pkg/types/types.go b/pkg/types/types.go index e5bc6a801..55e8b34c8 100644 --- a/pkg/types/types.go +++ b/pkg/types/types.go @@ -21,34 +21,57 @@ func (sa *StrIntArray) UnmarshalJSON(data []byte) error { if err != nil { return fmt.Errorf("unmarshal str int array: %w", err) } - switch obj := jsonObj.(type) { - case string: - *sa = StrIntArray([]string{obj}) + if obj, ok := jsonObj.([]any); ok { + s, err := intArrayToStrings(obj) + if err != nil { + return err + } + *sa = StrIntArray(s) return nil + } + + str, ok := scalarToString(jsonObj) + if !ok { + return ErrUnsupportedType + } + *sa = StrIntArray([]string{str}) + return nil +} + +func scalarToString(v any) (string, bool) { + switch value := v.(type) { + case string: + return value, true case int: - *sa = StrIntArray([]string{strconv.Itoa(obj)}) - return nil + return strconv.Itoa(value), true case float64: - *sa = StrIntArray([]string{strconv.Itoa(int(obj))}) - return nil - case []any: - s := make([]string, 0, len(obj)) - for _, v := range obj { - switch value := v.(type) { - case string: - s = append(s, value) - case int: - s = append(s, strconv.Itoa(value)) - case float64: - s = append(s, strconv.Itoa(int(value))) - default: - return ErrUnsupportedType - } + return strconv.Itoa(int(value)), true + } + return "", false +} + +func intArrayToStrings(arr []any) ([]string, error) { + s := make([]string, 0, len(arr)) + for _, v := range arr { + str, ok := scalarToString(v) + if !ok { + return nil, ErrUnsupportedType } - *sa = StrIntArray(s) - return nil + s = append(s, str) } - return ErrUnsupportedType + return s, nil +} + +func stringArray(arr []any) ([]string, error) { + s := make([]string, 0, len(arr)) + for _, v := range arr { + value, ok := v.(string) + if !ok { + return nil, ErrUnsupportedType + } + s = append(s, value) + } + return s, nil } // StrArray string array to be used on JSON UnmarshalJSON. @@ -98,41 +121,42 @@ func (l *LifecycleHook) UnmarshalJSON(data []byte) error { return nil case []any: // Anonymous array of strings command - cmd := make([]string, 0) - for _, v := range obj { - value, ok := v.(string) - if !ok { - return ErrUnsupportedType - } - cmd = append(cmd, value) + cmd, err := stringArray(obj) + if err != nil { + return err } (*l)[""] = cmd return nil case map[string]any: - for k, v := range obj { - value, ok := v.(string) - if ok { - // Named string command - (*l)[k] = []string{value} - } else { - // Named array of strings command - stringArrayValue, ok := v.([]any) - if !ok { - return ErrUnsupportedType - } - - cmd := make([]string, 0) - for _, v := range stringArrayValue { - cmd = append(cmd, v.(string)) - } - (*l)[k] = cmd - } + return l.parseNamedCommands(obj) + } + + return ErrUnsupportedType +} + +func (l *LifecycleHook) parseNamedCommands(obj map[string]any) error { + for k, v := range obj { + value, ok := v.(string) + if ok { + // Named string command + (*l)[k] = []string{value} + continue } - return nil + // Named array of strings command + stringArrayValue, ok := v.([]any) + if !ok { + return ErrUnsupportedType + } + + cmd, err := stringArray(stringArrayValue) + if err != nil { + return err + } + (*l)[k] = cmd } - return ErrUnsupportedType + return nil } type StrBool string @@ -184,6 +208,17 @@ func (e *OptionEnumArray) UnmarshalJSON(data []byte) error { *e = OptionEnumArray{} return nil } + + ret, err := parseOptionEnums(obj) + if err != nil { + return err + } + + *e = OptionEnumArray(ret) + return nil +} + +func parseOptionEnums(obj []any) ([]OptionEnum, error) { ret := make([]OptionEnum, 0, len(obj)) switch obj[0].(type) { case string: @@ -196,25 +231,28 @@ func (e *OptionEnumArray) UnmarshalJSON(data []byte) error { for _, v := range obj { m, ok := v.(map[string]any) if !ok { - return ErrUnsupportedType - } - value := "" - if s, ok := m["value"].(string); ok { - value = s - } - displayName := "" - if s, ok := m["displayName"].(string); ok { - displayName = s + return nil, ErrUnsupportedType } - ret = append(ret, OptionEnum{ - Value: value, - DisplayName: displayName, - }) + ret = append(ret, optionEnumFromMap(m)) } default: - return ErrUnsupportedType + return nil, ErrUnsupportedType } - *e = OptionEnumArray(ret) - return nil + return ret, nil +} + +func optionEnumFromMap(m map[string]any) OptionEnum { + value := "" + if s, ok := m["value"].(string); ok { + value = s + } + displayName := "" + if s, ok := m["displayName"].(string); ok { + displayName = s + } + return OptionEnum{ + Value: value, + DisplayName: displayName, + } } diff --git a/pkg/util/homedir.go b/pkg/util/homedir.go index 4930c0dae..da0ddcb7c 100644 --- a/pkg/util/homedir.go +++ b/pkg/util/homedir.go @@ -11,17 +11,15 @@ import ( "strings" ) +const homeEnvPlan9 = "home" + // UserHomeDir returns the home directory for the executing user. // // This extends the logic of os.UserHomeDir() with the now archived package // github.com/mitchellh/go-homedir for compatibility. func UserHomeDir() (string, error) { // Always try the HOME environment variable first - homeEnv := "HOME" - if runtime.GOOS == "plan9" { - homeEnv = "home" - } - if home := os.Getenv(homeEnv); home != "" { + if home := homeFromEnv(); home != "" { return home, nil } @@ -30,68 +28,109 @@ func UserHomeDir() (string, error) { return home, nil } - var stdout bytes.Buffer + // Handle cases that existed in go-homedir but not in the current + // os.UserHomeDir() implementation. + if home, done, err := homeFromPlatform(); done { + return home, err + } + + // If all else fails, try the shell + return homeFromShell() +} - // Finally, handle cases existed in go-homedir but not in the current - // os.UserHomeDir() implementation +func homeFromEnv() string { + homeEnv := "HOME" + if runtime.GOOS == "plan9" { + homeEnv = homeEnvPlan9 + } + return os.Getenv(homeEnv) +} + +// homeFromPlatform resolves the home directory using platform-specific +// mechanisms. done reports whether the resolution is authoritative; when +// false the caller should fall back to the shell. +func homeFromPlatform() (string, bool, error) { switch runtime.GOOS { case "windows": - drive := os.Getenv("HOMEDRIVE") - path := os.Getenv("HOMEPATH") - if drive == "" || path == "" { - return "", errors.New("HOMEDRIVE, HOMEPATH, or USERPROFILE are blank") - } - return drive + path, nil + home, err := homeFromWindows() + return home, true, err case "darwin": - cmd := exec.Command( - "sh", - "-c", - `dscl -q . -read /Users/"$(whoami)" NFSHomeDirectory | sed 's/^[^ ]*: //'`, - ) - cmd.Stdout = &stdout - if err := cmd.Run(); err == nil { - result := strings.TrimSpace(stdout.String()) - if result != "" { - return result, nil - } + if home := homeFromDarwin(); home != "" { + return home, true, nil } default: - cmd := exec.Command("getent", "passwd", strconv.Itoa(os.Getuid())) - cmd.Stdout = &stdout - if err := cmd.Run(); err != nil { - // If the error is ErrNotFound, we ignore it. Otherwise, return it. - if errors.Is(err, exec.ErrNotFound) { - return "", err - } - } else { - if passwd := strings.TrimSpace(stdout.String()); passwd != "" { - // username:password:uid:gid:gecos:home:shell - passwdParts := strings.SplitN(passwd, ":", 7) - if len(passwdParts) > 5 { - return passwdParts[5], nil - } - } + if home, done, err := homeFromGetent(); done { + return home, true, err } } + return "", false, nil +} - // If all else fails, try the shell - if runtime.GOOS != "windows" { - stdout.Reset() - cmd := exec.Command("sh", "-c", "cd && pwd") - cmd.Stdout = &stdout - if err := cmd.Run(); err != nil { - return "", err - } +func homeFromWindows() (string, error) { + drive := os.Getenv("HOMEDRIVE") + path := os.Getenv("HOMEPATH") + if drive == "" || path == "" { + return "", errors.New("HOMEDRIVE, HOMEPATH, or USERPROFILE are blank") + } + return drive + path, nil +} - result := strings.TrimSpace(stdout.String()) - if result == "" { - return "", errors.New("blank output when reading home directory") +func homeFromDarwin() string { + var stdout bytes.Buffer + cmd := exec.Command( + "sh", + "-c", + `dscl -q . -read /Users/"$(whoami)" NFSHomeDirectory | sed 's/^[^ ]*: //'`, + ) + cmd.Stdout = &stdout + if err := cmd.Run(); err != nil { + return "" + } + return strings.TrimSpace(stdout.String()) +} + +func homeFromGetent() (home string, done bool, err error) { + var stdout bytes.Buffer + // #nosec G204 -- shell/utility path resolved internally, arguments are not user-tainted + cmd := exec.Command("getent", "passwd", strconv.Itoa(os.Getuid())) + cmd.Stdout = &stdout + if runErr := cmd.Run(); runErr != nil { + // If the error is ErrNotFound, we return it. Otherwise, we ignore it. + if errors.Is(runErr, exec.ErrNotFound) { + return "", true, runErr } + return "", false, nil + } + passwd := strings.TrimSpace(stdout.String()) + if passwd == "" { + return "", false, nil + } + // username:password:uid:gid:gecos:home:shell + passwdParts := strings.SplitN(passwd, ":", 7) + if len(passwdParts) > 5 && passwdParts[5] != "" { + return passwdParts[5], true, nil + } + return "", false, nil +} + +func homeFromShell() (string, error) { + if runtime.GOOS == "windows" { + return "", errors.New("can't determine the home directory") + } + + var stdout bytes.Buffer + cmd := exec.Command("sh", "-c", "cd && pwd") + cmd.Stdout = &stdout + if err := cmd.Run(); err != nil { + return "", err + } - return result, nil + result := strings.TrimSpace(stdout.String()) + if result == "" { + return "", errors.New("blank output when reading home directory") } - return "", errors.New("can't determine the home directory") + return result, nil } // ExpandTilde expands environment variables and a leading ~ or ~/ to the diff --git a/pkg/workspace/exec.go b/pkg/workspace/exec.go index 4d4d98fe5..ae8eccb2a 100644 --- a/pkg/workspace/exec.go +++ b/pkg/workspace/exec.go @@ -533,9 +533,38 @@ func resolveExecTarget(ctx context.Context, opts ExecOneShotOptions) (resolvedEx } execResult := LoadExecResult(workspaceConfig, containerDetails) - workdir := opts.Workdir + target, workdir, envMap := buildExecTargetEnv(ctx, buildExecTargetEnvParams{ + runtime: runtime, + opts: opts, + execResult: execResult, + containerID: containerDetails.ID, + workspaceName: client.Workspace(), + }) + + return resolvedExecTarget{ + runtime: runtime, + target: target, + workdir: workdir, + envMap: envMap, + }, nil +} + +type buildExecTargetEnvParams struct { + runtime ContainerRuntime + opts ExecOneShotOptions + execResult *devcconfig.Result + containerID string + workspaceName string +} + +func buildExecTargetEnv( + ctx context.Context, + params buildExecTargetEnvParams, +) (ContainerTarget, string, map[string]string) { + execResult := params.execResult + workdir := params.opts.Workdir if workdir == "" { - workdir = ResolveExecWorkdir(execResult, client.Workspace()) + workdir = ResolveExecWorkdir(execResult, params.workspaceName) } user := "" @@ -544,7 +573,7 @@ func resolveExecTarget(ctx context.Context, opts ExecOneShotOptions) (resolvedEx } target := ContainerTarget{ - ContainerID: containerDetails.ID, + ContainerID: params.containerID, User: user, } @@ -552,16 +581,10 @@ func resolveExecTarget(ctx context.Context, opts ExecOneShotOptions) (resolvedEx if execResult != nil && execResult.MergedConfig != nil { userEnvProbe = execResult.MergedConfig.UserEnvProbe } - probedEnv := runtime.ProbeEnv(ctx, target, userEnvProbe) - envSlice := envMapToSlice(opts.Env) - envMap := BuildExecEnv(execResult, envSlice, probedEnv) + probedEnv := params.runtime.ProbeEnv(ctx, target, userEnvProbe) + envMap := BuildExecEnv(execResult, envMapToSlice(params.opts.Env), probedEnv) - return resolvedExecTarget{ - runtime: runtime, - target: target, - workdir: workdir, - envMap: envMap, - }, nil + return target, workdir, envMap } func envMapToSlice(m map[string]string) []string { diff --git a/pkg/workspace/list.go b/pkg/workspace/list.go index 67b1e2728..c4c3aa3ff 100644 --- a/pkg/workspace/list.go +++ b/pkg/workspace/list.go @@ -36,65 +36,75 @@ func List( proWorkspaces := []*providerpkg.Workspace{} if !skipPro { - // list remote workspaces - proWorkspaceResults, err := listProWorkspaces(ctx, devsyConfig, owner) + proWorkspaces, localWorkspaces, err = reconcileProWorkspaces( + ctx, devsyConfig, localWorkspaces, owner, + ) if err != nil { return nil, err } + } - // extract pure workspace list first - for _, result := range proWorkspaceResults { - proWorkspaces = append(proWorkspaces, result.workspaces...) - } + return mergeWorkspaces(localWorkspaces, proWorkspaces), nil +} - // Check if every local file based workspace has a remote counterpart - // If not, delete it - // However, we need to differentiate between workspaces that are legitimately not available anymore - // and the ones where we were temporarily not able to reach the host - cleanedLocalWorkspaces := []*providerpkg.Workspace{} - for _, localWorkspace := range localWorkspaces { - if localWorkspace.IsPro() { - if shouldDeleteLocalWorkspace(ctx, localWorkspace, proWorkspaceResults) { - err = clientimplementation.DeleteWorkspaceFolder( - clientimplementation.DeleteWorkspaceFolderParams{ - Context: devsyConfig.DefaultContext, - WorkspaceID: localWorkspace.ID, - SSHConfigPath: localWorkspace.SSHConfigPath, - SSHConfigIncludePath: localWorkspace.SSHConfigIncludePath, - }, - ) - if err != nil { - log.Debugf( - "failed to delete local workspace %s: %v", - localWorkspace.ID, - err, - ) - } - continue - } - } +func reconcileProWorkspaces( + ctx context.Context, + devsyConfig *config.Config, + localWorkspaces []*providerpkg.Workspace, + owner platform.OwnerFilter, +) ([]*providerpkg.Workspace, []*providerpkg.Workspace, error) { + proWorkspaceResults, err := listProWorkspaces(ctx, devsyConfig, owner) + if err != nil { + return nil, nil, err + } + + proWorkspaces := []*providerpkg.Workspace{} + for _, result := range proWorkspaceResults { + proWorkspaces = append(proWorkspaces, result.workspaces...) + } - cleanedLocalWorkspaces = append(cleanedLocalWorkspaces, localWorkspace) + // Check if every local file based workspace has a remote counterpart. + // If not, delete it, while differentiating between workspaces that are + // legitimately gone and those where the host was temporarily unreachable. + cleanedLocalWorkspaces := []*providerpkg.Workspace{} + for _, localWorkspace := range localWorkspaces { + if localWorkspace.IsPro() && + shouldDeleteLocalWorkspace(ctx, localWorkspace, proWorkspaceResults) { + deleteLocalWorkspace(devsyConfig, localWorkspace) + continue } - localWorkspaces = cleanedLocalWorkspaces + cleanedLocalWorkspaces = append(cleanedLocalWorkspaces, localWorkspace) } - // Set indexed by UID for deduplication - workspaces := map[string]*providerpkg.Workspace{} + return proWorkspaces, cleanedLocalWorkspaces, nil +} + +func deleteLocalWorkspace(devsyConfig *config.Config, localWorkspace *providerpkg.Workspace) { + err := clientimplementation.DeleteWorkspaceFolder( + clientimplementation.DeleteWorkspaceFolderParams{ + Context: devsyConfig.DefaultContext, + WorkspaceID: localWorkspace.ID, + SSHConfigPath: localWorkspace.SSHConfigPath, + SSHConfigIncludePath: localWorkspace.SSHConfigIncludePath, + }, + ) + if err != nil { + log.Debugf("failed to delete local workspace %s: %v", localWorkspace.ID, err) + } +} - // set local workspaces +func mergeWorkspaces( + localWorkspaces, proWorkspaces []*providerpkg.Workspace, +) []*providerpkg.Workspace { + workspaces := map[string]*providerpkg.Workspace{} for _, workspace := range localWorkspaces { workspaces[workspace.UID] = workspace } - // merge pro into local with pro taking precedence if UID matches for _, proWorkspace := range proWorkspaces { - localWorkspace, ok := workspaces[proWorkspace.UID] - if ok { - // we want to use the local workspace IDE configuration + if localWorkspace, ok := workspaces[proWorkspace.UID]; ok { proWorkspace.IDE = localWorkspace.IDE } - workspaces[proWorkspace.UID] = proWorkspace } @@ -103,7 +113,7 @@ func List( retWorkspaces = append(retWorkspaces, v) } - return retWorkspaces, nil + return retWorkspaces } func ListLocalWorkspaces( @@ -122,28 +132,41 @@ func ListLocalWorkspaces( retWorkspaces := []*providerpkg.Workspace{} for _, entry := range entries { - if strings.HasPrefix(entry.Name(), ".") { - continue + if workspaceConfig := loadLocalWorkspaceEntry( + contextName, + entry.Name(), + skipPro, + ); workspaceConfig != nil { + retWorkspaces = append(retWorkspaces, workspaceConfig) } + } - workspaceConfig, err := providerpkg.LoadWorkspaceConfig(contextName, entry.Name()) - if err != nil { - if os.IsNotExist(err) { - log.Debugf("skipping workspace without config: workspace=%s", entry.Name()) - } else { - log.Warnf("could not load workspace: workspace=%s, error=%v", entry.Name(), err) - } - continue - } + return retWorkspaces, nil +} - if skipPro && workspaceConfig.IsPro() { - continue +func loadLocalWorkspaceEntry( + contextName, name string, + skipPro bool, +) *providerpkg.Workspace { + if strings.HasPrefix(name, ".") { + return nil + } + + workspaceConfig, err := providerpkg.LoadWorkspaceConfig(contextName, name) + if err != nil { + if os.IsNotExist(err) { + log.Debugf("skipping workspace without config: workspace=%s", name) + } else { + log.Warnf("could not load workspace: workspace=%s, error=%v", name, err) } + return nil + } - retWorkspaces = append(retWorkspaces, workspaceConfig) + if skipPro && workspaceConfig.IsPro() { + return nil } - return retWorkspaces, nil + return workspaceConfig } func CountLocalWorkspaces(contextName string) (int, error) { @@ -214,23 +237,12 @@ func listProWorkspacesForProvider( providerConfig *providerpkg.ProviderConfig, owner platform.OwnerFilter, ) ([]*providerpkg.Workspace, error) { - var ( - instances []managementv1.DevsyWorkspaceInstance - err error - ) - switch { - case providerConfig.IsProxyProvider(): - instances, err = listInstancesProxyProvider( - ctx, - devsyConfig, - provider, - providerConfig, - ) - case providerConfig.IsDaemonProvider(): - instances, err = listInstancesDaemonProvider(ctx, provider, owner) - default: - return nil, fmt.Errorf("cannot list pro workspaces with provider %s", provider) - } + instances, err := listProInstances(ctx, listProInstancesParams{ + devsyConfig: devsyConfig, + provider: provider, + providerConfig: providerConfig, + owner: owner, + }) if err != nil { if log.DebugEnabled() { log.Warnf("Failed to list pro workspaces for provider %s: %v", provider, err) @@ -240,89 +252,128 @@ func listProWorkspacesForProvider( retWorkspaces := []*providerpkg.Workspace{} for _, instance := range instances { - if instance.GetLabels() == nil { - log.Debugf("no labels for pro workspace %q found, skipping", instance.GetName()) - continue + if workspace := proWorkspaceFromInstance( + instance, + provider, + devsyConfig.DefaultContext, + ); workspace != nil { + retWorkspaces = append(retWorkspaces, workspace) } + } - // id - id := instance.GetLabels()[storagev1.DevsyWorkspaceIDLabel] - if id == "" { - log.Debugf("no ID label for pro workspace %q found, skipping", instance.GetName()) - continue - } + return retWorkspaces, nil +} - // uid - uid := instance.GetLabels()[storagev1.DevsyWorkspaceUIDLabel] - if uid == "" { - log.Debugf("no UID label for pro workspace %q found, skipping", instance.GetName()) - continue - } +type listProInstancesParams struct { + devsyConfig *config.Config + provider string + providerConfig *providerpkg.ProviderConfig + owner platform.OwnerFilter +} - // project - projectName := instance.GetLabels()[config.K8sProjectLabel] - - // source - source := providerpkg.WorkspaceSource{} - if instance.Annotations != nil && - instance.Annotations[storagev1.DevsyWorkspaceSourceAnnotation] != "" { - // source to workspace config source - rawSource := instance.Annotations[storagev1.DevsyWorkspaceSourceAnnotation] - s := providerpkg.ParseWorkspaceSource(rawSource) - if s == nil { - log.Warnf("unable to parse workspace source: source=%s", rawSource) - } else { - source = *s - } - } +func listProInstances( + ctx context.Context, + params listProInstancesParams, +) ([]managementv1.DevsyWorkspaceInstance, error) { + switch { + case params.providerConfig.IsProxyProvider(): + return listInstancesProxyProvider( + ctx, + params.devsyConfig, + params.provider, + params.providerConfig, + ) + case params.providerConfig.IsDaemonProvider(): + return listInstancesDaemonProvider(ctx, params.provider, params.owner) + default: + return nil, fmt.Errorf("cannot list pro workspaces with provider %s", params.provider) + } +} - // last used timestamp - var lastUsedTimestamp types.Time - sleepModeConfig := instance.Status.SleepModeConfig - if sleepModeConfig != nil { - lastUsedTimestamp = types.Unix(sleepModeConfig.Status.LastActivity, 0) - } else { - var ts int64 - if instance.Annotations != nil { - if val, ok := instance.Annotations["sleepmode.devsy.sh/last-activity"]; ok { - var err error - if ts, err = strconv.ParseInt(val, 10, 64); err != nil { - log.Warn( - "received invalid sleepmode.devsy.sh/last-activity from ", - instance.GetName(), - ) - } - } - } - lastUsedTimestamp = types.Unix(ts, 0) - } +func proWorkspaceFromInstance( + instance managementv1.DevsyWorkspaceInstance, + provider string, + defaultContext string, +) *providerpkg.Workspace { + if instance.GetLabels() == nil { + log.Debugf("no labels for pro workspace %q found, skipping", instance.GetName()) + return nil + } - // creation timestamp - creationTimestamp := types.Time{} - if !instance.CreationTimestamp.IsZero() { - creationTimestamp = types.NewTime(instance.CreationTimestamp.Time) - } + id := instance.GetLabels()[storagev1.DevsyWorkspaceIDLabel] + if id == "" { + log.Debugf("no ID label for pro workspace %q found, skipping", instance.GetName()) + return nil + } - workspace := providerpkg.Workspace{ - ID: id, - UID: uid, - Context: devsyConfig.DefaultContext, - Source: source, - Provider: providerpkg.WorkspaceProviderConfig{ - Name: provider, - }, - LastUsedTimestamp: lastUsedTimestamp, - CreationTimestamp: creationTimestamp, - Pro: &providerpkg.ProMetadata{ - InstanceName: instance.GetName(), - Project: projectName, - DisplayName: instance.Spec.DisplayName, - }, + uid := instance.GetLabels()[storagev1.DevsyWorkspaceUIDLabel] + if uid == "" { + log.Debugf("no UID label for pro workspace %q found, skipping", instance.GetName()) + return nil + } + + return &providerpkg.Workspace{ + ID: id, + UID: uid, + Context: defaultContext, + Source: proWorkspaceSource(instance), + Provider: providerpkg.WorkspaceProviderConfig{ + Name: provider, + }, + LastUsedTimestamp: proWorkspaceLastUsed(instance), + CreationTimestamp: proWorkspaceCreationTimestamp(instance), + Pro: &providerpkg.ProMetadata{ + InstanceName: instance.GetName(), + Project: instance.GetLabels()[config.K8sProjectLabel], + DisplayName: instance.Spec.DisplayName, + }, + } +} + +func proWorkspaceSource( + instance managementv1.DevsyWorkspaceInstance, +) providerpkg.WorkspaceSource { + source := providerpkg.WorkspaceSource{} + if instance.Annotations == nil || + instance.Annotations[storagev1.DevsyWorkspaceSourceAnnotation] == "" { + return source + } + + rawSource := instance.Annotations[storagev1.DevsyWorkspaceSourceAnnotation] + s := providerpkg.ParseWorkspaceSource(rawSource) + if s == nil { + log.Warnf("unable to parse workspace source: source=%s", rawSource) + return source + } + return *s +} + +func proWorkspaceLastUsed(instance managementv1.DevsyWorkspaceInstance) types.Time { + if sleepModeConfig := instance.Status.SleepModeConfig; sleepModeConfig != nil { + return types.Unix(sleepModeConfig.Status.LastActivity, 0) + } + + var ts int64 + if instance.Annotations != nil { + if val, ok := instance.Annotations["sleepmode.devsy.sh/last-activity"]; ok { + if parsed, err := strconv.ParseInt(val, 10, 64); err != nil { + log.Warn( + "received invalid sleepmode.devsy.sh/last-activity from ", + instance.GetName(), + ) + } else { + ts = parsed + } } - retWorkspaces = append(retWorkspaces, &workspace) } + return types.Unix(ts, 0) +} - return retWorkspaces, nil +func proWorkspaceCreationTimestamp(instance managementv1.DevsyWorkspaceInstance) types.Time { + if instance.CreationTimestamp.IsZero() { + return types.Time{} + } + return types.NewTime(instance.CreationTimestamp.Time) } func shouldDeleteLocalWorkspace( diff --git a/pkg/workspace/workspace.go b/pkg/workspace/workspace.go index db50452cf..6f8c41cc5 100644 --- a/pkg/workspace/workspace.go +++ b/pkg/workspace/workspace.go @@ -123,6 +123,20 @@ func validateDesiredID(desiredID string) error { } func applyDevContainerOverrides(workspace *providerpkg.Workspace, params ResolveParams) error { + changed := applyDevContainerFields(workspace, params) + + if !changed && workspace.Source.Container == "" { + return nil + } + + if err := providerpkg.SaveWorkspaceConfig(workspace); err != nil { + return fmt.Errorf("save workspace: %w", err) + } + + return nil +} + +func applyDevContainerFields(workspace *providerpkg.Workspace, params ResolveParams) bool { changed := false if params.DevContainerImage != "" && workspace.DevContainerImage != params.DevContainerImage { workspace.DevContainerImage = params.DevContainerImage @@ -137,16 +151,7 @@ func applyDevContainerOverrides(workspace *providerpkg.Workspace, params Resolve workspace.DevContainerSource = params.DevContainerSource changed = true } - - if !changed && workspace.Source.Container == "" { - return nil - } - - if err := providerpkg.SaveWorkspaceConfig(workspace); err != nil { - return fmt.Errorf("save workspace: %w", err) - } - - return nil + return changed } func getWorkspaceClient( @@ -246,23 +251,8 @@ func resolveWorkspace( params ResolveParams, ) (*providerpkg.ProviderConfig, *providerpkg.Workspace, *providerpkg.Machine, error) { if len(params.Args) == 0 { - if params.DesiredID != "" { - workspace, err := findWorkspace(ctx, devsyConfig, nil, params.DesiredID, params.Owner) - if err != nil { - return nil, nil, nil, fmt.Errorf("find workspace: %w", err) - } - if workspace == nil { - return nil, nil, nil, fmt.Errorf("workspace %s doesn't exist", params.DesiredID) - } - return loadExistingWorkspace(devsyConfig, workspace.ID, params.ChangeLastUsed) - } - - return selectWorkspace(ctx, devsyConfig, selectWorkspaceParams{ - changeLastUsed: params.ChangeLastUsed, - sshConfigPath: params.SSHConfigPath, - sshConfigIncludePath: params.SSHConfigIncludePath, - owner: params.Owner, - }) + rw, err := resolveWorkspaceWithoutArgs(ctx, devsyConfig, params) + return rw.provider, rw.workspace, rw.machine, err } isLocalPath, name := file.IsLocalDir(params.Args[0]) @@ -305,6 +295,40 @@ func resolveWorkspace( return provider, workspace, machine, nil } +type resolvedWorkspace struct { + provider *providerpkg.ProviderConfig + workspace *providerpkg.Workspace + machine *providerpkg.Machine +} + +func resolveWorkspaceWithoutArgs( + ctx context.Context, + devsyConfig *config.Config, + params ResolveParams, +) (resolvedWorkspace, error) { + if params.DesiredID != "" { + workspace, err := findWorkspace(ctx, devsyConfig, nil, params.DesiredID, params.Owner) + if err != nil { + return resolvedWorkspace{}, fmt.Errorf("find workspace: %w", err) + } + if workspace == nil { + return resolvedWorkspace{}, fmt.Errorf("workspace %s doesn't exist", params.DesiredID) + } + provider, ws, machine, err := loadExistingWorkspace( + devsyConfig, workspace.ID, params.ChangeLastUsed, + ) + return resolvedWorkspace{provider, ws, machine}, err + } + + provider, ws, machine, err := selectWorkspace(ctx, devsyConfig, selectWorkspaceParams{ + changeLastUsed: params.ChangeLastUsed, + sshConfigPath: params.SSHConfigPath, + sshConfigIncludePath: params.SSHConfigIncludePath, + owner: params.Owner, + }) + return resolvedWorkspace{provider, ws, machine}, err +} + type createWorkspaceParams struct { workspaceID string name string @@ -343,20 +367,12 @@ func createWorkspace( return nil, nil, nil, err } - var machineConfig *providerpkg.Machine - switch { - case provider.Config.IsMachineProvider() && workspace.Machine.ID == "": - machineConfig, err = provisionManagedMachine(ctx, machineProvisionParams{ - devsyConfig: devsyConfig, - provider: provider, - workspace: workspace, - providerUserOptions: params.providerUserOptions, - }) - case provider.Config.IsProxyProvider() || provider.Config.IsDaemonProvider(): - workspace, err = resolveProWorkspace(ctx, devsyConfig, provider, workspace) - default: - machineConfig, err = saveAndLoadExistingMachine(provider, workspace) - } + workspace, machineConfig, err := provisionWorkspaceBacking(ctx, machineProvisionParams{ + devsyConfig: devsyConfig, + provider: provider, + workspace: workspace, + providerUserOptions: params.providerUserOptions, + }) if err != nil { return nil, nil, nil, err } @@ -364,6 +380,23 @@ func createWorkspace( return provider.Config, workspace, machineConfig, nil } +func provisionWorkspaceBacking( + ctx context.Context, + p machineProvisionParams, +) (*providerpkg.Workspace, *providerpkg.Machine, error) { + switch { + case p.provider.Config.IsMachineProvider() && p.workspace.Machine.ID == "": + machineConfig, err := provisionManagedMachine(ctx, p) + return p.workspace, machineConfig, err + case p.provider.Config.IsProxyProvider() || p.provider.Config.IsDaemonProvider(): + updated, err := resolveProWorkspace(ctx, p.devsyConfig, p.provider, p.workspace) + return updated, nil, err + default: + machineConfig, err := saveAndLoadExistingMachine(p.provider, p.workspace) + return p.workspace, machineConfig, err + } +} + func loadInitializedProvider(devsyConfig *config.Config) (*ProviderWithOptions, error) { provider, _, err := LoadProviders(devsyConfig) if err != nil { @@ -716,18 +749,47 @@ func selectWorkspace( return nil, nil, nil, err } + sel, err := selectProWorkspace( + devsyConfig, workspaces, selectedWorkspace, params, + ) + if err != nil { + return nil, nil, nil, err + } + if sel.handled { + return sel.provider, sel.workspace, nil, nil + } + + return loadExistingWorkspace(devsyConfig, selectedWorkspace.ID, params.changeLastUsed) +} + +type proWorkspaceSelection struct { + provider *providerpkg.ProviderConfig + workspace *providerpkg.Workspace + handled bool +} + +func selectProWorkspace( + devsyConfig *config.Config, + workspaces []*providerpkg.Workspace, + selectedWorkspace *providerpkg.Workspace, + params selectWorkspaceParams, +) (proWorkspaceSelection, error) { for _, workspace := range workspaces { if workspace.ID != selectedWorkspace.ID || !workspace.IsPro() { continue } providerConfig, err := importSelectedProWorkspace(devsyConfig, workspace, params) if err != nil { - return nil, nil, nil, err + return proWorkspaceSelection{}, err } - return providerConfig, workspace, nil, nil + return proWorkspaceSelection{ + provider: providerConfig, + workspace: workspace, + handled: true, + }, nil } - return loadExistingWorkspace(devsyConfig, selectedWorkspace.ID, params.changeLastUsed) + return proWorkspaceSelection{}, nil } // listSelectableWorkspaces lists the candidate workspaces, most recently used