diff --git a/cmd/up.go b/cmd/up.go index a0fc5cc9e..13783e993 100644 --- a/cmd/up.go +++ b/cmd/up.go @@ -82,6 +82,16 @@ func NewUpCmd(f *flags.GlobalFlags) *cobra.Command { Use: "up [flags] [workspace-path|workspace-name]", Short: "Starts a new workspace", RunE: func(cobraCmd *cobra.Command, args []string) error { + absExtraDevContainerPaths := []string{} + for _, extraPath := range cmd.ExtraDevContainerPaths { + absExtraPath, err := filepath.Abs(extraPath) + if err != nil { + return err + } + + absExtraDevContainerPaths = append(absExtraDevContainerPaths, absExtraPath) + } + cmd.ExtraDevContainerPaths = absExtraDevContainerPaths devPodConfig, err := config.LoadConfig(cmd.Context, cmd.Provider) if err != nil { return err @@ -98,6 +108,11 @@ func NewUpCmd(f *flags.GlobalFlags) *cobra.Command { if err != nil { return fmt.Errorf("prepare workspace client: %w", err) } + + if len(cmd.ExtraDevContainerPaths)!=0 && client.Provider() != "docker" { + return fmt.Errorf("Extra devcontainer file is only supported with local provider") + } + telemetry.CollectorCLI.SetClient(client) return cmd.Run(ctx, devPodConfig, client, args, logger) @@ -113,6 +128,7 @@ func NewUpCmd(f *flags.GlobalFlags) *cobra.Command { upCmd.Flags().StringArrayVar(&cmd.IDEOptions, "ide-option", []string{}, "IDE option in the form KEY=VALUE") 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().StringArrayVar(&cmd.ExtraDevContainerPaths, "extra-devcontainer-path", []string{}, "The path to additional devcontainer.json files to override original devcontainer.json") 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 DevPod Pro right now.") upCmd.Flags().BoolVar(&cmd.Recreate, "recreate", false, "If true will remove any existing containers and recreate them") diff --git a/docs/pages/developing-in-workspaces/create-a-workspace.mdx b/docs/pages/developing-in-workspaces/create-a-workspace.mdx index 001852150..239d35dfc 100644 --- a/docs/pages/developing-in-workspaces/create-a-workspace.mdx +++ b/docs/pages/developing-in-workspaces/create-a-workspace.mdx @@ -13,6 +13,11 @@ Upon successful creation, DevPod will make the development container available t A workspace is defined through a `devcontainer.json`. If DevPod can't find one, it will automatically try to guess the programming language of your project and provide a fitting template. ::: +:::info +It is possible to override a 'devcontainer.json' with specific user settings such as mounts by creating a file named 'devcontainer.user.json' in the same directory as the 'devcontainer.json' of the workspace. +This can be useful when customization of a versioned devcontainer is needed. +::: + ### Via DevPod Desktop Application Navigate to the 'Workspaces' view and click on the 'Create' button in the title. Enter the git repository you want to work on or select a local folder. @@ -34,7 +39,7 @@ Under the hood, the Desktop Application will call the CLI command `devpod up REP ::: :::info Note -You can set the location of your devpod home by passing the `--devpod-home={home_path}` flag, +You can set the location of your devpod home by passing the `--devpod-home={home_path}` flag, or by setting the env var `DEVPOD_HOME` to your desired home directory. This can be useful if you are having trouble with a workspace trying to mount to a windows location when it should be mounting to a path inside the WSL VM. @@ -107,7 +112,7 @@ DevPod will create the following `.devcontainer.json`: If you have a local container running, you can create a workspace from it by running: ``` -devpod up my-workspace --source container:$CONTAINER_ID +devpod up my-workspace --source container:$CONTAINER_ID ``` This only works with the `docker` provider. @@ -148,4 +153,4 @@ Navigate to the 'Workspaces' view and press on the 'More Options' button on the Run the following command to reset an existing workspace: ``` devpod up my-workspace --reset -``` \ No newline at end of file +``` diff --git a/pkg/devcontainer/compose.go b/pkg/devcontainer/compose.go index 12a6a06c3..c1f549886 100644 --- a/pkg/devcontainer/compose.go +++ b/pkg/devcontainer/compose.go @@ -200,6 +200,21 @@ func (r *runner) runDockerCompose( return nil, errors.Wrap(err, "get image metadata from container") } + userConfig, err := config.ParseDevContainerUserJSON(parsedConfig.Config) + if err != nil { + return nil, err + } else if userConfig != nil { + config.AddConfigToImageMetadata(userConfig, imageMetadataConfig) + } + + for _, v := range options.ExtraDevContainerPaths { + extraConfig, err := config.ParseDevContainerJSONFile(v) + if err != nil { + return nil, err + } + config.AddConfigToImageMetadata(extraConfig, imageMetadataConfig) + } + mergedConfig, err := config.MergeConfiguration(parsedConfig.Config, imageMetadataConfig.Config) if err != nil { return nil, errors.Wrap(err, "merge config") @@ -351,6 +366,21 @@ func (r *runner) startContainer( return nil, errors.Wrap(err, "inspect image") } + userConfig, err := config.ParseDevContainerUserJSON(parsedConfig.Config) + if err != nil { + return nil, err + } else if userConfig != nil { + config.AddConfigToImageMetadata(userConfig, imageMetadata) + } + + for _, v := range options.ExtraDevContainerPaths { + extraConfig, err := config.ParseDevContainerJSONFile(v) + if err != nil { + return nil, err + } + config.AddConfigToImageMetadata(extraConfig, imageMetadata) + } + mergedConfig, err := config.MergeConfiguration(parsedConfig.Config, imageMetadata.Config) if err != nil { return nil, errors.Wrap(err, "merge configuration") diff --git a/pkg/devcontainer/config/metadata.go b/pkg/devcontainer/config/metadata.go index a0278b981..5d7f0dd82 100644 --- a/pkg/devcontainer/config/metadata.go +++ b/pkg/devcontainer/config/metadata.go @@ -12,3 +12,13 @@ type ImageMetadata struct { DevContainerActions `json:",inline"` NonComposeBase `json:",inline"` } + +// AddConfigToImageMetadata add a configuration to the given image metadata. +// This will be used to generate the final image metadata. +func AddConfigToImageMetadata(config *DevContainerConfig, imageMetadataConfig *ImageMetadataConfig) { + userMetadata := &ImageMetadata{} + userMetadata.DevContainerConfigBase = config.DevContainerConfigBase + userMetadata.DevContainerActions = config.DevContainerActions + userMetadata.NonComposeBase = config.NonComposeBase + imageMetadataConfig.Config = append(imageMetadataConfig.Config, userMetadata) +} diff --git a/pkg/devcontainer/config/parse.go b/pkg/devcontainer/config/parse.go index 0dc56bf03..afa7c2ebc 100644 --- a/pkg/devcontainer/config/parse.go +++ b/pkg/devcontainer/config/parse.go @@ -67,6 +67,49 @@ func SaveDevContainerJSON(config *DevContainerConfig) error { return nil } +// ParseDevContainerJSONFile parse the given a devcontainer.json file. +func ParseDevContainerJSONFile(jsonFilePath string) (*DevContainerConfig, error) { + var err error + path, err := filepath.Abs(jsonFilePath) + if err != nil { + return nil, errors.Wrap(err, "make path absolute") + } + + bytes, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + devContainer := &DevContainerConfig{} + err = json.Unmarshal(jsonc.ToJSON(bytes), devContainer) + if err != nil { + return nil, err + } + devContainer.Origin = path + return replaceLegacy(devContainer) +} + +// ParseDevContainerUserJSON check if a file named devcontainer.user.json exists in the same directory as +// the devcontainer.json file and parse it if it does. +func ParseDevContainerUserJSON(config *DevContainerConfig) (*DevContainerConfig, error) { + filename := filepath.Base(config.Origin) + filename = strings.TrimSuffix(filename, filepath.Ext(filename)) + + devContainerUserUserFilename := fmt.Sprintf("%s.user.json", filename) + devContainerUserUserFilePath := filepath.Join(filepath.Dir(config.Origin), devContainerUserUserFilename) + + _, err := os.Stat(devContainerUserUserFilePath) + if err == nil { + userConfig, err := ParseDevContainerJSONFile(devContainerUserUserFilePath) + if err != nil { + return nil, err + } + return userConfig, nil + } + return nil, nil +} + +// ParseDevContainerJSON check if a file named devcontainer.json exists in the given directory and parse it if it does func ParseDevContainerJSON(folder, relativePath string) (*DevContainerConfig, error) { path := "" if relativePath != "" { @@ -91,26 +134,7 @@ func ParseDevContainerJSON(folder, relativePath string) (*DevContainerConfig, er } } } - - var err error - path, err = filepath.Abs(path) - if err != nil { - return nil, errors.Wrap(err, "make path absolute") - } - - bytes, err := os.ReadFile(path) - if err != nil { - return nil, err - } - - devContainer := &DevContainerConfig{} - err = json.Unmarshal(jsonc.ToJSON(bytes), devContainer) - if err != nil { - return nil, err - } - - devContainer.Origin = path - return replaceLegacy(devContainer) + return ParseDevContainerJSONFile(path) } func replaceLegacy(config *DevContainerConfig) (*DevContainerConfig, error) { diff --git a/pkg/devcontainer/single.go b/pkg/devcontainer/single.go index fb45954e5..e1a4a6a22 100644 --- a/pkg/devcontainer/single.go +++ b/pkg/devcontainer/single.go @@ -73,6 +73,21 @@ func (r *runner) runSingleContainer( return nil, err } + userConfig, err := config.ParseDevContainerUserJSON(parsedConfig.Config) + if err != nil { + return nil, err + } else if userConfig != nil { + config.AddConfigToImageMetadata(userConfig, imageMetadataConfig) + } + + for _, v := range options.ExtraDevContainerPaths { + extraConfig, err := config.ParseDevContainerJSONFile(v) + if err != nil { + return nil, err + } + config.AddConfigToImageMetadata(extraConfig, imageMetadataConfig) + } + mergedConfig, err = config.MergeConfiguration(parsedConfig.Config, imageMetadataConfig.Config) if err != nil { return nil, errors.Wrap(err, "merge config") @@ -122,6 +137,21 @@ func (r *runner) runSingleContainer( } } + userConfig, err := config.ParseDevContainerUserJSON(parsedConfig.Config) + if err != nil { + return nil, err + } else if userConfig != nil { + config.AddConfigToImageMetadata(userConfig, buildInfo.ImageMetadata) + } + + for _, v := range options.ExtraDevContainerPaths { + extraConfig, err := config.ParseDevContainerJSONFile(v) + if err != nil { + return nil, err + } + config.AddConfigToImageMetadata(extraConfig, buildInfo.ImageMetadata) + } + // merge configuration mergedConfig, err = config.MergeConfiguration(parsedConfig.Config, buildInfo.ImageMetadata.Config) if err != nil { diff --git a/pkg/provider/workspace.go b/pkg/provider/workspace.go index baf445281..5929473a5 100644 --- a/pkg/provider/workspace.go +++ b/pkg/provider/workspace.go @@ -222,6 +222,7 @@ type CLIOptions struct { GitSSHSigningKey string `json:"gitSshSigningKey,omitempty"` SSHAuthSockID string `json:"sshAuthSockID,omitempty"` // ID to use when looking for SSH_AUTH_SOCK, defaults to a new random ID if not set (only used for browser IDEs) StrictHostKeyChecking bool `json:"strictHostKeyChecking,omitempty"` + ExtraDevContainerPaths []string `json:"extraDevContainerPaths,omitempty"` // build options Repository string `json:"repository,omitempty"`