From 067bba87110038540b34c64e1fcd3e13adb3f1c0 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Tue, 5 May 2026 11:36:11 -0500 Subject: [PATCH] refactor(cmd): extract flags, validation, and client prep from up.go --- cmd/up.go | 452 --------------------------------------------- cmd/up_client.go | 163 ++++++++++++++++ cmd/up_flags.go | 216 ++++++++++++++++++++++ cmd/up_validate.go | 101 ++++++++++ 4 files changed, 480 insertions(+), 452 deletions(-) create mode 100644 cmd/up_client.go create mode 100644 cmd/up_flags.go create mode 100644 cmd/up_validate.go diff --git a/cmd/up.go b/cmd/up.go index f59fc1633..0c8c5cb31 100644 --- a/cmd/up.go +++ b/cmd/up.go @@ -7,8 +7,6 @@ import ( "os" "os/signal" "path/filepath" - "strconv" - "strings" "syscall" "github.com/devsy-org/devsy/cmd/flags" @@ -23,25 +21,13 @@ import ( "github.com/devsy-org/devsy/pkg/ide" "github.com/devsy-org/devsy/pkg/ide/opener" "github.com/devsy-org/devsy/pkg/log" - options2 "github.com/devsy-org/devsy/pkg/options" provider2 "github.com/devsy-org/devsy/pkg/provider" - "github.com/devsy-org/devsy/pkg/secrets" devssh "github.com/devsy-org/devsy/pkg/ssh" "github.com/devsy-org/devsy/pkg/telemetry" "github.com/devsy-org/devsy/pkg/util" - workspace2 "github.com/devsy-org/devsy/pkg/workspace" "github.com/spf13/cobra" ) -const ( - MountConsistencyConsistent = "consistent" - MountConsistencyCached = "cached" - MountConsistencyDelegated = "delegated" - - UpdateRemoteUserUIDOn = "on" - UpdateRemoteUserUIDOff = "off" -) - // UpCmd holds the up cmd flags. type UpCmd struct { provider2.CLIOptions @@ -152,261 +138,6 @@ func (cmd *UpCmd) execute(cobraCmd *cobra.Command, args []string) error { return cmd.Run(ctx, devsyConfig, client, args) } -func (cmd *UpCmd) validate() error { - if err := validatePodmanFlags(cmd); err != nil { - return err - } - if err := config2.ValidateIDLabels(cmd.IDLabels); err != nil { - return err - } - if cmd.DefaultUserEnvProbe != "" { - if _, err := config2.NewUserEnvProbe(cmd.DefaultUserEnvProbe); err != nil { - return err - } - } - if cmd.ExtraDevContainerPath != "" { - absPath, err := filepath.Abs(cmd.ExtraDevContainerPath) - if err != nil { - return err - } - cmd.ExtraDevContainerPath = absPath - } - if cmd.WorkspaceMountConsistency != "" { - switch cmd.WorkspaceMountConsistency { - case MountConsistencyConsistent, MountConsistencyCached, MountConsistencyDelegated: - default: - return fmt.Errorf( - "invalid --workspace-mount-consistency value %q: must be one of %s, %s, %s", - cmd.WorkspaceMountConsistency, - MountConsistencyConsistent, MountConsistencyCached, MountConsistencyDelegated, - ) - } - } - if cmd.UpdateRemoteUserUIDDefault != "" { - switch cmd.UpdateRemoteUserUIDDefault { - case UpdateRemoteUserUIDOn, UpdateRemoteUserUIDOff: - default: - return fmt.Errorf( - "invalid --update-remote-user-uid-default value %q: must be \"on\" or \"off\"", - cmd.UpdateRemoteUserUIDDefault, - ) - } - } - return nil -} - -func (cmd *UpCmd) registerFlags(upCmd *cobra.Command) { - cmd.registerSSHFlags(upCmd) - cmd.registerDotfilesFlags(upCmd) - cmd.registerDevContainerFlags(upCmd) - cmd.registerIDEFlags(upCmd) - cmd.registerGitFlags(upCmd) - cmd.registerPodmanFlags(upCmd) - cmd.registerWorkspaceFlags(upCmd) - cmd.registerTestingFlags(upCmd) -} - -func (cmd *UpCmd) registerSSHFlags(upCmd *cobra.Command) { - upCmd.Flags(). - BoolVar(&cmd.ConfigureSSH, "configure-ssh", true, - "If true will configure the ssh config to include the Devsy workspace") - upCmd.Flags(). - BoolVar(&cmd.GPGAgentForwarding, "gpg-agent-forwarding", false, - "If true forward the local gpg-agent to the Devsy workspace") - upCmd.Flags(). - StringVar(&cmd.SSHConfigPath, "ssh-config", "", - "The path to the ssh config to modify, if empty will use ~/.ssh/config") -} - -func (cmd *UpCmd) registerDotfilesFlags(upCmd *cobra.Command) { - upCmd.Flags(). - StringVar(&cmd.DotfilesSource, "dotfiles", "", "The path or url to the dotfiles to use in the container") - upCmd.Flags().StringVar(&cmd.DotfilesSource, "dotfiles-repository", "", "Alias for --dotfiles") - _ = upCmd.Flags().MarkHidden("dotfiles-repository") - upCmd.Flags(). - StringVar(&cmd.DotfilesScript, "dotfiles-script", "", - "The path in dotfiles directory to use to install the dotfiles, if empty will try to guess") - upCmd.Flags(). - StringVar(&cmd.DotfilesTargetPath, "dotfiles-target-path", "", - "The target path inside the container to install dotfiles to (e.g., ~/dotfiles)") - upCmd.Flags(). - StringSliceVar(&cmd.DotfilesScriptEnv, "dotfiles-script-env", []string{}, - "Extra environment variables to put into the dotfiles install script, e.g. MY_ENV_VAR=MY_VALUE") - upCmd.Flags(). - StringSliceVar(&cmd.DotfilesScriptEnvFile, "dotfiles-script-env-file", []string{}, - "The path to files containing environment variables to set for the dotfiles install script") -} - -func (cmd *UpCmd) registerDevContainerFlags(upCmd *cobra.Command) { - upCmd.Flags(). - StringVar(&cmd.DevContainerImage, "devcontainer-image", "", - "The container image to use, this will override the devcontainer.json value in the project") - upCmd.Flags(). - StringVar(&cmd.DevContainerPath, "devcontainer-path", "", "The path to the devcontainer.json relative to the project") - upCmd.Flags().StringVar(&cmd.DevContainerPath, "config", "", "Alias for --devcontainer-path") - _ = upCmd.Flags().MarkHidden("config") - upCmd.Flags(). - StringVar(&cmd.DevContainerID, "devcontainer-id", "", - "The ID of the devcontainer to use when multiple exist "+ - "(e.g., folder name in .devcontainer/FOLDER/devcontainer.json)") - upCmd.Flags(). - StringVar(&cmd.ExtraDevContainerPath, "extra-devcontainer-path", "", - "The path to an additional devcontainer.json file to override original devcontainer.json") - upCmd.Flags(). - StringVar(&cmd.ExtraDevContainerPath, "override-config", "", "Alias for --extra-devcontainer-path") - _ = upCmd.Flags().MarkHidden("override-config") - upCmd.Flags(). - StringVar(&cmd.FallbackImage, "fallback-image", "", - "The fallback image to use if no devcontainer configuration has been detected") - upCmd.Flags(). - StringVar(&cmd.AdditionalFeatures, "additional-features", "", - `Additional features to apply to the dev container (JSON as per "features" section in devcontainer.json)`) - upCmd.Flags(). - StringArrayVar(&cmd.IDLabels, "id-label", []string{}, - "Override the default container identification labels (format: key=value, can be specified multiple times)") - upCmd.Flags(). - StringVar(&cmd.DefaultUserEnvProbe, "default-user-env-probe", "", - "Override userEnvProbe from devcontainer.json (loginInteractiveShell, loginShell, interactiveShell, none)") - upCmd.Flags(). - StringVar(&cmd.GPUAvailability, "gpu-availability", "", - "Override GPU availability detection (detect, true, false)") - upCmd.Flags(). - StringVar(&cmd.UpdateRemoteUserUIDDefault, "update-remote-user-uid-default", "", - "Default for updateRemoteUserUID when not set in devcontainer.json (on, off)") - upCmd.Flags(). - StringVar(&cmd.ContainerDataFolder, "container-data-folder", "", - "Custom path for container-specific data") - defaultMountGitRoot := true - cmd.MountWorkspaceGitRoot = &defaultMountGitRoot - upCmd.Flags(). - BoolVar(cmd.MountWorkspaceGitRoot, "mount-workspace-git-root", true, - "Mount the workspace git root as the workspace folder") - upCmd.Flags(). - IntVar(&cmd.TerminalColumns, "terminal-columns", 0, - "Terminal column count for lifecycle scripts") - upCmd.Flags(). - IntVar(&cmd.TerminalRows, "terminal-rows", 0, - "Terminal row count for lifecycle scripts") - upCmd.Flags(). - BoolVar(&cmd.SkipPostCreate, "skip-post-create", false, - "Skip the postCreateCommand lifecycle hook") - upCmd.Flags(). - BoolVar(&cmd.SkipNonBlockingCommands, "skip-non-blocking-commands", false, - "Skip non-blocking lifecycle commands") - upCmd.Flags(). - BoolVar(&cmd.SkipPostStart, "skip-post-start", false, - "Skip running postStartCommand") - upCmd.Flags(). - BoolVar(&cmd.SkipPostAttach, "skip-post-attach", false, - "Skip running postAttachCommand") - upCmd.Flags(). - StringVar(&cmd.ContainerUser, "container-user", "", - "Override the user in the container") - upCmd.Flags(). - StringVar(&cmd.RemoteUser, "remote-user", "", - "Override the remoteUser setting") -} - -func (cmd *UpCmd) registerIDEFlags(upCmd *cobra.Command) { - upCmd.Flags(). - StringVar(&cmd.IDE, "ide", "", "The IDE to open the workspace in. If empty will use vscode locally or in browser") - upCmd.Flags(). - StringArrayVar(&cmd.IDEOptions, "ide-option", []string{}, "IDE option in the form KEY=VALUE") - upCmd.Flags(). - BoolVar(&cmd.OpenIDE, "open-ide", true, - "If this is false and an IDE is configured, Devsy will only install the IDE server backend, but not open it") -} - -func (cmd *UpCmd) registerGitFlags(upCmd *cobra.Command) { - upCmd.Flags(). - Var(&cmd.GitCloneStrategy, "git-clone-strategy", - "The git clone strategy Devsy uses to checkout git based workspaces. "+ - "Can be full (default), blobless, treeless or shallow") - upCmd.Flags(). - BoolVar(&cmd.GitCloneRecursiveSubmodules, "git-clone-recursive-submodules", false, - "If true will clone git submodule repositories recursively") - upCmd.Flags(). - StringVar(&cmd.GitSSHSigningKey, "git-ssh-signing-key", "", - "The ssh key to use when signing git commits. Used to explicitly setup Devsy's ssh signature "+ - "forwarding with given key. Should be same format as value of `git config user.signingkey`") -} - -func (cmd *UpCmd) registerPodmanFlags(upCmd *cobra.Command) { - upCmd.Flags(). - StringVar(&cmd.Userns, "userns", "", - "User namespace to use for the container (Podman only; e.g. \"keep-id\", \"host\", or \"auto\")") - upCmd.Flags(). - StringSliceVar(&cmd.UidMap, "uidmap", []string{}, - "UID mapping for Podman user namespace "+ - "(Podman only; format: container_id:host_id:amount, e.g. \"0:1000:1\")") - upCmd.Flags(). - StringSliceVar(&cmd.GidMap, "gidmap", []string{}, - "GID mapping for Podman user namespace "+ - "(Podman only; format: container_id:host_id:amount, e.g. \"0:1000:1\")") -} - -func (cmd *UpCmd) registerWorkspaceFlags(upCmd *cobra.Command) { - upCmd.Flags().StringVar(&cmd.ID, "id", "", "The id to use for the workspace") - upCmd.Flags(). - StringVar(&cmd.Machine, "machine", "", - "The machine to use for this workspace. The machine needs to exist beforehand or the "+ - "command will fail. If the workspace already exists, this option has no effect") - upCmd.Flags(). - StringVar(&cmd.Source, "source", "", "Optional source for the workspace, e.g. git:https://github.com/my-org/my-repo") - upCmd.Flags(). - StringArrayVar(&cmd.ProviderOptions, "provider-option", []string{}, "Provider option in the form KEY=VALUE") - upCmd.Flags(). - BoolVar(&cmd.Reconfigure, "reconfigure", false, - "Reconfigure the options for this workspace. Only supported in Devsy Pro right now.") - upCmd.Flags(). - BoolVar(&cmd.Prebuild, "prebuild", false, - "If true will only run the prebuild lifecycle (onCreateCommand + updateContentCommand) then stop") - upCmd.Flags(). - BoolVar(&cmd.Recreate, "recreate", false, "If true will remove any existing containers and recreate them") - upCmd.Flags().BoolVar(&cmd.Recreate, "remove-existing-container", false, "Alias for --recreate") - _ = upCmd.Flags().MarkHidden("remove-existing-container") - upCmd.Flags(). - BoolVar(&cmd.Reset, "reset", false, - "If true will remove any existing containers including sources, and recreate them") - upCmd.Flags(). - StringSliceVar(&cmd.PrebuildRepositories, "prebuild-repository", []string{}, - "Docker repository that hosts devsy prebuilds for this workspace") - upCmd.Flags(). - StringArrayVar(&cmd.WorkspaceEnv, "workspace-env", []string{}, - "Extra env variables to put into the workspace, e.g. MY_ENV_VAR=MY_VALUE") - upCmd.Flags(). - StringSliceVar(&cmd.WorkspaceEnvFile, "workspace-env-file", []string{}, - "The path to files containing a list of extra env variables to put into the workspace, "+ - "e.g. MY_ENV_VAR=MY_VALUE") - upCmd.Flags(). - StringVar(&cmd.SecretsFile, "secrets-file", "", - "Path to a dotenv-style file containing KEY=VALUE secrets injected into lifecycle commands") - upCmd.Flags(). - StringVar(&cmd.FeatureSecretsFile, "feature-secrets-file", "", - "Path to a JSON file containing secret values for features, format: "+ - `{"featureId": {"optionName": "value"}}`) - upCmd.Flags(). - StringArrayVar(&cmd.InitEnv, "init-env", []string{}, - "Extra env variables to inject during the initialization of the workspace, e.g. MY_ENV_VAR=MY_VALUE") - upCmd.Flags(). - BoolVar(&cmd.DisableDaemon, "disable-daemon", false, - "If enabled, will not install a daemon into the target machine to track activity") - upCmd.Flags(). - StringArrayVar(&cmd.CacheFrom, "cache-from", []string{}, - "Cache sources for the build (e.g., myregistry.io/cache:latest or type=registry,ref=...). "+ - "Takes priority over devcontainer.json build.cacheFrom") - upCmd.Flags(). - StringVar(&cmd.WorkspaceMountConsistency, "workspace-mount-consistency", "", - "Consistency mode for the workspace bind mount (consistent, cached, delegated)") -} - -func (cmd *UpCmd) registerTestingFlags(upCmd *cobra.Command) { - upCmd.Flags().StringVar(&cmd.DaemonInterval, "daemon-interval", "", "TESTING ONLY") - _ = upCmd.Flags().MarkHidden("daemon-interval") - upCmd.Flags().BoolVar(&cmd.ForceDockerless, "force-dockerless", false, "TESTING ONLY") - _ = upCmd.Flags().MarkHidden("force-dockerless") -} - // workspaceContext holds the result of workspace preparation. type workspaceContext struct { result *config2.Result @@ -850,151 +581,6 @@ func configureSSH(client client2.BaseWorkspaceClient, params configureSSHParams) return nil } -func mergeDevsyUpOptions(baseOptions *provider2.CLIOptions) error { - oldOptions := *baseOptions - found, err := clientimplementation.DecodeOptionsFromEnv( - config.EnvFlagsUp, - baseOptions, - ) - if err != nil { - return fmt.Errorf("decode up options: %w", err) - } else if found { - baseOptions.WorkspaceEnv = append(oldOptions.WorkspaceEnv, baseOptions.WorkspaceEnv...) - baseOptions.InitEnv = append(oldOptions.InitEnv, baseOptions.InitEnv...) - baseOptions.PrebuildRepositories = append( - oldOptions.PrebuildRepositories, - baseOptions.PrebuildRepositories...) - baseOptions.IDEOptions = append(oldOptions.IDEOptions, baseOptions.IDEOptions...) - } - - err = clientimplementation.DecodePlatformOptionsFromEnv(&baseOptions.Platform) - if err != nil { - return fmt.Errorf("decode platform options: %w", err) - } - - return nil -} - -func mergeEnvFromFiles(baseOptions *provider2.CLIOptions) error { - var variables []string - for _, file := range baseOptions.WorkspaceEnvFile { - envFromFile, err := config2.ParseKeyValueFile(file) - if err != nil { - return err - } - variables = append(variables, envFromFile...) - } - baseOptions.WorkspaceEnv = append(baseOptions.WorkspaceEnv, variables...) - - return nil -} - -var inheritedEnvironmentVariables = []string{ - "GIT_AUTHOR_NAME", - "GIT_AUTHOR_EMAIL", - "GIT_AUTHOR_DATE", - "GIT_COMMITTER_NAME", - "GIT_COMMITTER_EMAIL", - "GIT_COMMITTER_DATE", -} - -func (cmd *UpCmd) prepareClient( - ctx context.Context, - devsyConfig *config.Config, - args []string, -) (client2.BaseWorkspaceClient, error) { - // try to parse flags from env - if err := mergeDevsyUpOptions(&cmd.CLIOptions); err != nil { - return nil, err - } - - if cmd.Platform.Enabled { - log.Debug("Running in platform mode") - log.Debug("Using error output stream") - - // merge context options from env - config.MergeContextOptions(devsyConfig.Current(), os.Environ()) - } - - if err := mergeEnvFromFiles(&cmd.CLIOptions); err != nil { - return nil, err - } - - if cmd.SecretsFile != "" { - parsed, err := secrets.ParseSecretsFile(cmd.SecretsFile) - if err != nil { - return nil, err - } - for k, v := range parsed { - cmd.SecretsEnv = append(cmd.SecretsEnv, k+"="+v) - } - } - - if cmd.FeatureSecretsFile == "" { - cmd.FeatureSecretsFile = os.Getenv("DEVCONTAINER_SECRETS_FILE") - } - if cmd.FeatureSecretsFile != "" { - cmd.CLIOptions.FeatureSecretsFile = cmd.FeatureSecretsFile - } - - cmd.WorkspaceEnv = options2.InheritFromEnvironment( - cmd.WorkspaceEnv, - inheritedEnvironmentVariables, - "", - ) - - var source *provider2.WorkspaceSource - if cmd.Source != "" { - source = provider2.ParseWorkspaceSource(cmd.Source) - if source == nil { - return nil, fmt.Errorf("workspace source is missing") - } else if source.LocalFolder != "" && cmd.Platform.Enabled { - return nil, fmt.Errorf("local folder is not supported in platform mode. " + - "Please specify a Git repository instead") - } - } - - if cmd.SSHConfigPath == "" { - cmd.SSHConfigPath = devsyConfig.ContextOption(config.ContextOptionSSHConfigPath) - } - sshConfigIncludePath := devsyConfig.ContextOption(config.ContextOptionSSHConfigIncludePath) - - client, err := workspace2.Resolve( - ctx, - devsyConfig, - workspace2.ResolveParams{ - IDE: cmd.IDE, - IDEOptions: cmd.IDEOptions, - Args: args, - DesiredID: cmd.ID, - DesiredMachine: cmd.Machine, - ProviderUserOptions: cmd.ProviderOptions, - ReconfigureProvider: cmd.Reconfigure, - DevContainerImage: cmd.DevContainerImage, - DevContainerPath: cmd.DevContainerPath, - SSHConfigPath: cmd.SSHConfigPath, - SSHConfigIncludePath: sshConfigIncludePath, - Source: source, - UID: cmd.UID, - ChangeLastUsed: true, - Owner: cmd.Owner, - }, - ) - if err != nil { - return nil, err - } - - if !cmd.Platform.Enabled { - proInstance := workspace2.GetProInstance(devsyConfig, client.Provider()) - err = workspace2.CheckProviderUpdate(devsyConfig, proInstance) - if err != nil { - return nil, err - } - } - - return client, nil -} - func WithSignals(ctx context.Context) (context.Context, func()) { ctx, cancel := context.WithCancel(ctx) signals := make(chan os.Signal, 1) @@ -1019,41 +605,3 @@ func WithSignals(ctx context.Context) (context.Context, func()) { signal.Stop(signals) } } - -func validatePodmanFlags(cmd *UpCmd) error { - if cmd.Userns != "" && (len(cmd.UidMap) > 0 || len(cmd.GidMap) > 0) { - return fmt.Errorf( - "--userns cannot be combined with --uidmap or --gidmap (mutually exclusive)", - ) - } - for _, m := range cmd.UidMap { - if !isValidMapping(m) { - return fmt.Errorf( - "invalid --uidmap format: %s (expected: container_id:host_id:amount)", - m, - ) - } - } - for _, m := range cmd.GidMap { - if !isValidMapping(m) { - return fmt.Errorf( - "invalid --gidmap format: %s (expected: container_id:host_id:amount)", - m, - ) - } - } - return nil -} - -func isValidMapping(mapping string) bool { - parts := strings.Split(mapping, ":") - if len(parts) != 3 { - return false - } - for _, part := range parts { - if _, err := strconv.Atoi(part); err != nil { - return false - } - } - return true -} diff --git a/cmd/up_client.go b/cmd/up_client.go new file mode 100644 index 000000000..81092b14c --- /dev/null +++ b/cmd/up_client.go @@ -0,0 +1,163 @@ +package cmd + +import ( + "context" + "fmt" + "os" + + client2 "github.com/devsy-org/devsy/pkg/client" + "github.com/devsy-org/devsy/pkg/client/clientimplementation" + "github.com/devsy-org/devsy/pkg/config" + config2 "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/devsy-org/devsy/pkg/log" + options2 "github.com/devsy-org/devsy/pkg/options" + provider2 "github.com/devsy-org/devsy/pkg/provider" + "github.com/devsy-org/devsy/pkg/secrets" + workspace2 "github.com/devsy-org/devsy/pkg/workspace" +) + +func mergeDevsyUpOptions(baseOptions *provider2.CLIOptions) error { + oldOptions := *baseOptions + found, err := clientimplementation.DecodeOptionsFromEnv( + config.EnvFlagsUp, + baseOptions, + ) + if err != nil { + return fmt.Errorf("decode up options: %w", err) + } else if found { + baseOptions.WorkspaceEnv = append(oldOptions.WorkspaceEnv, baseOptions.WorkspaceEnv...) + baseOptions.InitEnv = append(oldOptions.InitEnv, baseOptions.InitEnv...) + baseOptions.PrebuildRepositories = append( + oldOptions.PrebuildRepositories, + baseOptions.PrebuildRepositories...) + baseOptions.IDEOptions = append(oldOptions.IDEOptions, baseOptions.IDEOptions...) + } + + err = clientimplementation.DecodePlatformOptionsFromEnv(&baseOptions.Platform) + if err != nil { + return fmt.Errorf("decode platform options: %w", err) + } + + return nil +} + +func mergeEnvFromFiles(baseOptions *provider2.CLIOptions) error { + var variables []string + for _, file := range baseOptions.WorkspaceEnvFile { + envFromFile, err := config2.ParseKeyValueFile(file) + if err != nil { + return err + } + variables = append(variables, envFromFile...) + } + baseOptions.WorkspaceEnv = append(baseOptions.WorkspaceEnv, variables...) + + return nil +} + +var inheritedEnvironmentVariables = []string{ + "GIT_AUTHOR_NAME", + "GIT_AUTHOR_EMAIL", + "GIT_AUTHOR_DATE", + "GIT_COMMITTER_NAME", + "GIT_COMMITTER_EMAIL", + "GIT_COMMITTER_DATE", +} + +//nolint:cyclop,funlen +func (cmd *UpCmd) prepareClient( + ctx context.Context, + devsyConfig *config.Config, + args []string, +) (client2.BaseWorkspaceClient, error) { + // try to parse flags from env + if err := mergeDevsyUpOptions(&cmd.CLIOptions); err != nil { + return nil, err + } + + if cmd.Platform.Enabled { + log.Debug("Running in platform mode") + log.Debug("Using error output stream") + + // merge context options from env + config.MergeContextOptions(devsyConfig.Current(), os.Environ()) + } + + if err := mergeEnvFromFiles(&cmd.CLIOptions); err != nil { + return nil, err + } + + if cmd.SecretsFile != "" { + parsed, err := secrets.ParseSecretsFile(cmd.SecretsFile) + if err != nil { + return nil, err + } + for k, v := range parsed { + cmd.SecretsEnv = append(cmd.SecretsEnv, k+"="+v) + } + } + + if cmd.FeatureSecretsFile == "" { + cmd.FeatureSecretsFile = os.Getenv("DEVCONTAINER_SECRETS_FILE") + } + if cmd.FeatureSecretsFile != "" { + cmd.CLIOptions.FeatureSecretsFile = cmd.FeatureSecretsFile + } + + cmd.WorkspaceEnv = options2.InheritFromEnvironment( + cmd.WorkspaceEnv, + inheritedEnvironmentVariables, + "", + ) + + var source *provider2.WorkspaceSource + if cmd.Source != "" { + source = provider2.ParseWorkspaceSource(cmd.Source) + if source == nil { + return nil, fmt.Errorf("workspace source is missing") + } else if source.LocalFolder != "" && cmd.Platform.Enabled { + return nil, fmt.Errorf("local folder is not supported in platform mode. " + + "Please specify a Git repository instead") + } + } + + if cmd.SSHConfigPath == "" { + cmd.SSHConfigPath = devsyConfig.ContextOption(config.ContextOptionSSHConfigPath) + } + sshConfigIncludePath := devsyConfig.ContextOption(config.ContextOptionSSHConfigIncludePath) + + client, err := workspace2.Resolve( + ctx, + devsyConfig, + workspace2.ResolveParams{ + IDE: cmd.IDE, + IDEOptions: cmd.IDEOptions, + Args: args, + DesiredID: cmd.ID, + DesiredMachine: cmd.Machine, + ProviderUserOptions: cmd.ProviderOptions, + ReconfigureProvider: cmd.Reconfigure, + DevContainerImage: cmd.DevContainerImage, + DevContainerPath: cmd.DevContainerPath, + SSHConfigPath: cmd.SSHConfigPath, + SSHConfigIncludePath: sshConfigIncludePath, + Source: source, + UID: cmd.UID, + ChangeLastUsed: true, + Owner: cmd.Owner, + }, + ) + if err != nil { + return nil, err + } + + if !cmd.Platform.Enabled { + proInstance := workspace2.GetProInstance(devsyConfig, client.Provider()) + err = workspace2.CheckProviderUpdate(devsyConfig, proInstance) + if err != nil { + return nil, err + } + } + + return client, nil +} diff --git a/cmd/up_flags.go b/cmd/up_flags.go new file mode 100644 index 000000000..ad69a9455 --- /dev/null +++ b/cmd/up_flags.go @@ -0,0 +1,216 @@ +package cmd + +import "github.com/spf13/cobra" + +func (cmd *UpCmd) registerFlags(upCmd *cobra.Command) { + cmd.registerSSHFlags(upCmd) + cmd.registerDotfilesFlags(upCmd) + cmd.registerDevContainerFlags(upCmd) + cmd.registerIDEFlags(upCmd) + cmd.registerGitFlags(upCmd) + cmd.registerPodmanFlags(upCmd) + cmd.registerWorkspaceFlags(upCmd) + cmd.registerTestingFlags(upCmd) +} + +func (cmd *UpCmd) registerSSHFlags(upCmd *cobra.Command) { + upCmd.Flags(). + BoolVar(&cmd.ConfigureSSH, "configure-ssh", true, + "If true will configure the ssh config to include the Devsy workspace") + upCmd.Flags(). + BoolVar(&cmd.GPGAgentForwarding, "gpg-agent-forwarding", false, + "If true forward the local gpg-agent to the Devsy workspace") + upCmd.Flags(). + StringVar(&cmd.SSHConfigPath, "ssh-config", "", + "The path to the ssh config to modify, if empty will use ~/.ssh/config") +} + +func (cmd *UpCmd) registerDotfilesFlags(upCmd *cobra.Command) { + upCmd.Flags(). + StringVar(&cmd.DotfilesSource, "dotfiles", "", "The path or url to the dotfiles to use in the container") + upCmd.Flags().StringVar(&cmd.DotfilesSource, "dotfiles-repository", "", "Alias for --dotfiles") + _ = upCmd.Flags().MarkHidden("dotfiles-repository") + upCmd.Flags(). + StringVar(&cmd.DotfilesScript, "dotfiles-script", "", + "The path in dotfiles directory to use to install the dotfiles, if empty will try to guess") + upCmd.Flags(). + StringVar(&cmd.DotfilesTargetPath, "dotfiles-target-path", "", + "The target path inside the container to install dotfiles to (e.g., ~/dotfiles)") + upCmd.Flags(). + StringSliceVar(&cmd.DotfilesScriptEnv, "dotfiles-script-env", []string{}, + "Extra environment variables to put into the dotfiles install script, e.g. MY_ENV_VAR=MY_VALUE") + upCmd.Flags(). + StringSliceVar(&cmd.DotfilesScriptEnvFile, "dotfiles-script-env-file", []string{}, + "The path to files containing environment variables to set for the dotfiles install script") +} + +//nolint:funlen +func (cmd *UpCmd) registerDevContainerFlags(upCmd *cobra.Command) { + upCmd.Flags(). + StringVar(&cmd.DevContainerImage, "devcontainer-image", "", + "The container image to use, this will override the devcontainer.json value in the project") + upCmd.Flags(). + StringVar(&cmd.DevContainerPath, "devcontainer-path", "", "The path to the devcontainer.json relative to the project") + upCmd.Flags().StringVar(&cmd.DevContainerPath, "config", "", "Alias for --devcontainer-path") + _ = upCmd.Flags().MarkHidden("config") + upCmd.Flags(). + StringVar(&cmd.DevContainerID, "devcontainer-id", "", + "The ID of the devcontainer to use when multiple exist "+ + "(e.g., folder name in .devcontainer/FOLDER/devcontainer.json)") + upCmd.Flags(). + StringVar(&cmd.ExtraDevContainerPath, "extra-devcontainer-path", "", + "The path to an additional devcontainer.json file to override original devcontainer.json") + upCmd.Flags(). + StringVar(&cmd.ExtraDevContainerPath, "override-config", "", "Alias for --extra-devcontainer-path") + _ = upCmd.Flags().MarkHidden("override-config") + upCmd.Flags(). + StringVar(&cmd.FallbackImage, "fallback-image", "", + "The fallback image to use if no devcontainer configuration has been detected") + upCmd.Flags(). + StringVar(&cmd.AdditionalFeatures, "additional-features", "", + `Additional features to apply to the dev container (JSON as per "features" section in devcontainer.json)`) + upCmd.Flags(). + StringArrayVar(&cmd.IDLabels, "id-label", []string{}, + "Override the default container identification labels (format: key=value, can be specified multiple times)") + upCmd.Flags(). + StringVar(&cmd.DefaultUserEnvProbe, "default-user-env-probe", "", + "Override userEnvProbe from devcontainer.json (loginInteractiveShell, loginShell, interactiveShell, none)") + upCmd.Flags(). + StringVar(&cmd.GPUAvailability, "gpu-availability", "", + "Override GPU availability detection (detect, true, false)") + upCmd.Flags(). + StringVar(&cmd.UpdateRemoteUserUIDDefault, "update-remote-user-uid-default", "", + "Default for updateRemoteUserUID when not set in devcontainer.json (on, off)") + upCmd.Flags(). + StringVar(&cmd.ContainerDataFolder, "container-data-folder", "", + "Custom path for container-specific data") + defaultMountGitRoot := true + cmd.MountWorkspaceGitRoot = &defaultMountGitRoot + upCmd.Flags(). + BoolVar(cmd.MountWorkspaceGitRoot, "mount-workspace-git-root", true, + "Mount the workspace git root as the workspace folder") + upCmd.Flags(). + IntVar(&cmd.TerminalColumns, "terminal-columns", 0, + "Terminal column count for lifecycle scripts") + upCmd.Flags(). + IntVar(&cmd.TerminalRows, "terminal-rows", 0, + "Terminal row count for lifecycle scripts") + upCmd.Flags(). + BoolVar(&cmd.SkipPostCreate, "skip-post-create", false, + "Skip the postCreateCommand lifecycle hook") + upCmd.Flags(). + BoolVar(&cmd.SkipNonBlockingCommands, "skip-non-blocking-commands", false, + "Skip non-blocking lifecycle commands") + upCmd.Flags(). + BoolVar(&cmd.SkipPostStart, "skip-post-start", false, + "Skip running postStartCommand") + upCmd.Flags(). + BoolVar(&cmd.SkipPostAttach, "skip-post-attach", false, + "Skip running postAttachCommand") + upCmd.Flags(). + StringVar(&cmd.ContainerUser, "container-user", "", + "Override the user in the container") + upCmd.Flags(). + StringVar(&cmd.RemoteUser, "remote-user", "", + "Override the remoteUser setting") +} + +func (cmd *UpCmd) registerIDEFlags(upCmd *cobra.Command) { + upCmd.Flags(). + StringVar(&cmd.IDE, "ide", "", "The IDE to open the workspace in. If empty will use vscode locally or in browser") + upCmd.Flags(). + StringArrayVar(&cmd.IDEOptions, "ide-option", []string{}, "IDE option in the form KEY=VALUE") + upCmd.Flags(). + BoolVar(&cmd.OpenIDE, "open-ide", true, + "If this is false and an IDE is configured, Devsy will only install the IDE server backend, but not open it") +} + +func (cmd *UpCmd) registerGitFlags(upCmd *cobra.Command) { + upCmd.Flags(). + Var(&cmd.GitCloneStrategy, "git-clone-strategy", + "The git clone strategy Devsy uses to checkout git based workspaces. "+ + "Can be full (default), blobless, treeless or shallow") + upCmd.Flags(). + BoolVar(&cmd.GitCloneRecursiveSubmodules, "git-clone-recursive-submodules", false, + "If true will clone git submodule repositories recursively") + upCmd.Flags(). + StringVar(&cmd.GitSSHSigningKey, "git-ssh-signing-key", "", + "The ssh key to use when signing git commits. Used to explicitly setup Devsy's ssh signature "+ + "forwarding with given key. Should be same format as value of `git config user.signingkey`") +} + +func (cmd *UpCmd) registerPodmanFlags(upCmd *cobra.Command) { + upCmd.Flags(). + StringVar(&cmd.Userns, "userns", "", + "User namespace to use for the container (Podman only; e.g. \"keep-id\", \"host\", or \"auto\")") + upCmd.Flags(). + StringSliceVar(&cmd.UidMap, "uidmap", []string{}, + "UID mapping for Podman user namespace "+ + "(Podman only; format: container_id:host_id:amount, e.g. \"0:1000:1\")") + upCmd.Flags(). + StringSliceVar(&cmd.GidMap, "gidmap", []string{}, + "GID mapping for Podman user namespace "+ + "(Podman only; format: container_id:host_id:amount, e.g. \"0:1000:1\")") +} + +func (cmd *UpCmd) registerWorkspaceFlags(upCmd *cobra.Command) { + upCmd.Flags().StringVar(&cmd.ID, "id", "", "The id to use for the workspace") + upCmd.Flags(). + StringVar(&cmd.Machine, "machine", "", + "The machine to use for this workspace. The machine needs to exist beforehand or the "+ + "command will fail. If the workspace already exists, this option has no effect") + upCmd.Flags(). + StringVar(&cmd.Source, "source", "", "Optional source for the workspace, e.g. git:https://github.com/my-org/my-repo") + upCmd.Flags(). + StringArrayVar(&cmd.ProviderOptions, "provider-option", []string{}, "Provider option in the form KEY=VALUE") + upCmd.Flags(). + BoolVar(&cmd.Reconfigure, "reconfigure", false, + "Reconfigure the options for this workspace. Only supported in Devsy Pro right now.") + upCmd.Flags(). + BoolVar(&cmd.Prebuild, "prebuild", false, + "If true will only run the prebuild lifecycle (onCreateCommand + updateContentCommand) then stop") + upCmd.Flags(). + BoolVar(&cmd.Recreate, "recreate", false, "If true will remove any existing containers and recreate them") + upCmd.Flags().BoolVar(&cmd.Recreate, "remove-existing-container", false, "Alias for --recreate") + _ = upCmd.Flags().MarkHidden("remove-existing-container") + upCmd.Flags(). + BoolVar(&cmd.Reset, "reset", false, + "If true will remove any existing containers including sources, and recreate them") + upCmd.Flags(). + StringSliceVar(&cmd.PrebuildRepositories, "prebuild-repository", []string{}, + "Docker repository that hosts devsy prebuilds for this workspace") + upCmd.Flags(). + StringArrayVar(&cmd.WorkspaceEnv, "workspace-env", []string{}, + "Extra env variables to put into the workspace, e.g. MY_ENV_VAR=MY_VALUE") + upCmd.Flags(). + StringSliceVar(&cmd.WorkspaceEnvFile, "workspace-env-file", []string{}, + "The path to files containing a list of extra env variables to put into the workspace, "+ + "e.g. MY_ENV_VAR=MY_VALUE") + upCmd.Flags(). + StringVar(&cmd.SecretsFile, "secrets-file", "", + "Path to a dotenv-style file containing KEY=VALUE secrets injected into lifecycle commands") + upCmd.Flags(). + StringVar(&cmd.FeatureSecretsFile, "feature-secrets-file", "", + "Path to a JSON file containing secret values for features, format: "+ + `{"featureId": {"optionName": "value"}}`) + upCmd.Flags(). + StringArrayVar(&cmd.InitEnv, "init-env", []string{}, + "Extra env variables to inject during the initialization of the workspace, e.g. MY_ENV_VAR=MY_VALUE") + upCmd.Flags(). + BoolVar(&cmd.DisableDaemon, "disable-daemon", false, + "If enabled, will not install a daemon into the target machine to track activity") + upCmd.Flags(). + StringArrayVar(&cmd.CacheFrom, "cache-from", []string{}, + "Cache sources for the build (e.g., myregistry.io/cache:latest or type=registry,ref=...). "+ + "Takes priority over devcontainer.json build.cacheFrom") + upCmd.Flags(). + StringVar(&cmd.WorkspaceMountConsistency, "workspace-mount-consistency", "", + "Consistency mode for the workspace bind mount (consistent, cached, delegated)") +} + +func (cmd *UpCmd) registerTestingFlags(upCmd *cobra.Command) { + upCmd.Flags().StringVar(&cmd.DaemonInterval, "daemon-interval", "", "TESTING ONLY") + _ = upCmd.Flags().MarkHidden("daemon-interval") + upCmd.Flags().BoolVar(&cmd.ForceDockerless, "force-dockerless", false, "TESTING ONLY") + _ = upCmd.Flags().MarkHidden("force-dockerless") +} diff --git a/cmd/up_validate.go b/cmd/up_validate.go new file mode 100644 index 000000000..31e3ebe0b --- /dev/null +++ b/cmd/up_validate.go @@ -0,0 +1,101 @@ +package cmd + +import ( + "fmt" + "path/filepath" + "strconv" + "strings" + + config2 "github.com/devsy-org/devsy/pkg/devcontainer/config" +) + +const ( + MountConsistencyConsistent = "consistent" + MountConsistencyCached = "cached" + MountConsistencyDelegated = "delegated" + + UpdateRemoteUserUIDOn = "on" + UpdateRemoteUserUIDOff = "off" +) + +//nolint:cyclop +func (cmd *UpCmd) validate() error { + if err := validatePodmanFlags(cmd); err != nil { + return err + } + if err := config2.ValidateIDLabels(cmd.IDLabels); err != nil { + return err + } + if cmd.DefaultUserEnvProbe != "" { + if _, err := config2.NewUserEnvProbe(cmd.DefaultUserEnvProbe); err != nil { + return err + } + } + if cmd.ExtraDevContainerPath != "" { + absPath, err := filepath.Abs(cmd.ExtraDevContainerPath) + if err != nil { + return err + } + cmd.ExtraDevContainerPath = absPath + } + if cmd.WorkspaceMountConsistency != "" { + switch cmd.WorkspaceMountConsistency { + case MountConsistencyConsistent, MountConsistencyCached, MountConsistencyDelegated: + default: + return fmt.Errorf( + "invalid --workspace-mount-consistency value %q: must be one of %s, %s, %s", + cmd.WorkspaceMountConsistency, + MountConsistencyConsistent, MountConsistencyCached, MountConsistencyDelegated, + ) + } + } + if cmd.UpdateRemoteUserUIDDefault != "" { + switch cmd.UpdateRemoteUserUIDDefault { + case UpdateRemoteUserUIDOn, UpdateRemoteUserUIDOff: + default: + return fmt.Errorf( + "invalid --update-remote-user-uid-default value %q: must be \"on\" or \"off\"", + cmd.UpdateRemoteUserUIDDefault, + ) + } + } + return nil +} + +func validatePodmanFlags(cmd *UpCmd) error { + if cmd.Userns != "" && (len(cmd.UidMap) > 0 || len(cmd.GidMap) > 0) { + return fmt.Errorf( + "--userns cannot be combined with --uidmap or --gidmap (mutually exclusive)", + ) + } + for _, m := range cmd.UidMap { + if !isValidMapping(m) { + return fmt.Errorf( + "invalid --uidmap format: %s (expected: container_id:host_id:amount)", + m, + ) + } + } + for _, m := range cmd.GidMap { + if !isValidMapping(m) { + return fmt.Errorf( + "invalid --gidmap format: %s (expected: container_id:host_id:amount)", + m, + ) + } + } + return nil +} + +func isValidMapping(mapping string) bool { + parts := strings.Split(mapping, ":") + if len(parts) != 3 { + return false + } + for _, part := range parts { + if _, err := strconv.Atoi(part); err != nil { + return false + } + } + return true +}