diff --git a/cmd/agent/workspace/up.go b/cmd/agent/workspace/up.go index e74fc18db..5c6d51027 100644 --- a/cmd/agent/workspace/up.go +++ b/cmd/agent/workspace/up.go @@ -81,7 +81,7 @@ func (cmd *UpCmd) Run(ctx context.Context) error { tunnelClient, logger, credentialsDir, err := initWorkspace(cancelCtx, cancel, workspaceInfo, cmd.Debug, !workspaceInfo.CLIOptions.Platform.Enabled && !workspaceInfo.CLIOptions.DisableDaemon) if err != nil { - err1 := clientimplementation.DeleteWorkspaceFolder(workspaceInfo.Workspace.Context, workspaceInfo.Workspace.ID, workspaceInfo.Workspace.SSHConfigPath, logger) + err1 := clientimplementation.DeleteWorkspaceFolder(workspaceInfo.Workspace.Context, workspaceInfo.Workspace.ID, workspaceInfo.Workspace.SSHConfigPath, workspaceInfo.Workspace.SSHConfigIncludePath, logger) if err1 != nil { return fmt.Errorf("%s %w", err1.Error(), err) } diff --git a/cmd/build.go b/cmd/build.go index 0d05aa2f1..a9ab2e61e 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -88,6 +88,7 @@ func NewBuildCmd(flags *flags.GlobalFlags) *cobra.Command { cmd.DevContainerImage, cmd.DevContainerPath, sshConfigPath, + "", nil, cmd.UID, false, diff --git a/cmd/pro/delete.go b/cmd/pro/delete.go index a7dfd2716..68aef6ee9 100644 --- a/cmd/pro/delete.go +++ b/cmd/pro/delete.go @@ -143,7 +143,7 @@ func cleanupLocalWorkspaces(ctx context.Context, devPodConfig *config.Config, wo return } // delete workspace folder - err = clientimplementation.DeleteWorkspaceFolder(devPodConfig.DefaultContext, client.Workspace(), client.WorkspaceConfig().SSHConfigPath, log) + err = clientimplementation.DeleteWorkspaceFolder(devPodConfig.DefaultContext, client.Workspace(), client.WorkspaceConfig().SSHConfigPath, client.WorkspaceConfig().SSHConfigIncludePath, log) if err != nil { log.WithFields(logrus.Fields{ "workspaceId": w.ID, diff --git a/cmd/ssh.go b/cmd/ssh.go index e9f2e7720..96e9c6b8d 100644 --- a/cmd/ssh.go +++ b/cmd/ssh.go @@ -128,7 +128,7 @@ func (cmd *SSHCmd) Run( // get user if cmd.User == "" { var err error - cmd.User, err = devssh.GetUser(client.WorkspaceConfig().ID, client.WorkspaceConfig().SSHConfigPath) + cmd.User, err = devssh.GetUser(client.WorkspaceConfig().ID, client.WorkspaceConfig().SSHConfigPath, client.WorkspaceConfig().SSHConfigIncludePath) if err != nil { return err } diff --git a/cmd/up.go b/cmd/up.go index 93693e1b1..53488e606 100644 --- a/cmd/up.go +++ b/cmd/up.go @@ -213,8 +213,9 @@ func (cmd *UpCmd) Run( devPodHome = envDevPodHome } setupGPGAgentForwarding := cmd.GPGAgentForwarding || devPodConfig.ContextOption(config.ContextOptionGPGAgentForwarding) == "true" + sshConfigIncludePath := devPodConfig.ContextOption(config.ContextOptionSSHConfigIncludePath) - err = configureSSH(client, cmd.SSHConfigPath, user, workdir, setupGPGAgentForwarding, devPodHome) + err = configureSSH(client, cmd.SSHConfigPath, sshConfigIncludePath, user, workdir, setupGPGAgentForwarding, devPodHome) if err != nil { return err } @@ -850,7 +851,7 @@ func setupBackhaul(client client2.BaseWorkspaceClient, authSockId string, log lo return err } - remoteUser, err := devssh.GetUser(client.WorkspaceConfig().ID, client.WorkspaceConfig().SSHConfigPath) + remoteUser, err := devssh.GetUser(client.WorkspaceConfig().ID, client.WorkspaceConfig().SSHConfigPath, client.WorkspaceConfig().SSHConfigIncludePath) if err != nil { remoteUser = "root" } @@ -1002,15 +1003,24 @@ func startBrowserTunnel( return nil } -func configureSSH(client client2.BaseWorkspaceClient, sshConfigPath, user, workdir string, gpgagent bool, devPodHome string) error { +func configureSSH(client client2.BaseWorkspaceClient, sshConfigPath, sshConfigIncludePath, user, workdir string, gpgagent bool, devPodHome string) error { path, err := devssh.ResolveSSHConfigPath(sshConfigPath) if err != nil { return fmt.Errorf("invalid ssh config path %w", err) } sshConfigPath = path + if sshConfigIncludePath != "" { + includePath, err := devssh.ResolveSSHConfigPath(sshConfigIncludePath) + if err != nil { + return fmt.Errorf("invalid ssh config include path %w", err) + } + sshConfigIncludePath = includePath + } + err = devssh.ConfigureSSHConfig( sshConfigPath, + sshConfigIncludePath, client.Context(), client.Workspace(), user, @@ -1195,7 +1205,7 @@ func buildDotCmd(devPodConfig *config.Config, dotfilesRepo, dotfilesScript strin sshCmd = append(sshCmd, "--send-env", envKey) } - remoteUser, err := devssh.GetUser(client.WorkspaceConfig().ID, client.WorkspaceConfig().SSHConfigPath) + remoteUser, err := devssh.GetUser(client.WorkspaceConfig().ID, client.WorkspaceConfig().SSHConfigPath, client.WorkspaceConfig().SSHConfigIncludePath) if err != nil { remoteUser = "root" } @@ -1254,7 +1264,7 @@ func setupGitSSHSignature(signingKey string, client client2.BaseWorkspaceClient, return err } - remoteUser, err := devssh.GetUser(client.WorkspaceConfig().ID, client.WorkspaceConfig().SSHConfigPath) + remoteUser, err := devssh.GetUser(client.WorkspaceConfig().ID, client.WorkspaceConfig().SSHConfigPath, client.WorkspaceConfig().SSHConfigIncludePath) if err != nil { remoteUser = "root" } @@ -1288,7 +1298,7 @@ func performGpgForwarding( return err } - remoteUser, err := devssh.GetUser(client.WorkspaceConfig().ID, client.WorkspaceConfig().SSHConfigPath) + remoteUser, err := devssh.GetUser(client.WorkspaceConfig().ID, client.WorkspaceConfig().SSHConfigPath, client.WorkspaceConfig().SSHConfigIncludePath) if err != nil { remoteUser = "root" } @@ -1435,6 +1445,7 @@ func (cmd *UpCmd) prepareClient(ctx context.Context, devPodConfig *config.Config if cmd.SSHConfigPath == "" { cmd.SSHConfigPath = devPodConfig.ContextOption(config.ContextOptionSSHConfigPath) } + sshConfigIncludePath := devPodConfig.ContextOption(config.ContextOptionSSHConfigIncludePath) client, err := workspace2.Resolve( ctx, @@ -1449,6 +1460,7 @@ func (cmd *UpCmd) prepareClient(ctx context.Context, devPodConfig *config.Config cmd.DevContainerImage, cmd.DevContainerPath, cmd.SSHConfigPath, + sshConfigIncludePath, source, cmd.UID, true, diff --git a/pkg/client/clientimplementation/daemonclient/delete.go b/pkg/client/clientimplementation/daemonclient/delete.go index a5c2df03b..4c2b5e86c 100644 --- a/pkg/client/clientimplementation/daemonclient/delete.go +++ b/pkg/client/clientimplementation/daemonclient/delete.go @@ -27,7 +27,7 @@ func (c *client) Delete(ctx context.Context, opt clientpkg.DeleteOptions) error return err } else if workspace == nil { // delete the workspace folder - err = clientimplementation.DeleteWorkspaceFolder(c.workspace.Context, c.workspace.ID, c.workspace.SSHConfigPath, c.log) + err = clientimplementation.DeleteWorkspaceFolder(c.workspace.Context, c.workspace.ID, c.workspace.SSHConfigPath, c.workspace.SSHConfigIncludePath, c.log) if err != nil { return err } @@ -67,7 +67,7 @@ func (c *client) Delete(ctx context.Context, opt clientpkg.DeleteOptions) error } // delete the workspace folder - err = clientimplementation.DeleteWorkspaceFolder(c.workspace.Context, c.workspace.ID, c.workspace.SSHConfigPath, c.log) + err = clientimplementation.DeleteWorkspaceFolder(c.workspace.Context, c.workspace.ID, c.workspace.SSHConfigPath, c.workspace.SSHConfigIncludePath, c.log) if err != nil { return err } diff --git a/pkg/client/clientimplementation/proxy_client.go b/pkg/client/clientimplementation/proxy_client.go index 24778df16..9e7ee2115 100644 --- a/pkg/client/clientimplementation/proxy_client.go +++ b/pkg/client/clientimplementation/proxy_client.go @@ -317,7 +317,7 @@ func (s *proxyClient) Delete(ctx context.Context, opt client.DeleteOptions) erro s.log.Errorf("Error deleting workspace: %v", err) } - return DeleteWorkspaceFolder(s.workspace.Context, s.workspace.ID, s.workspace.SSHConfigPath, s.log) + return DeleteWorkspaceFolder(s.workspace.Context, s.workspace.ID, s.workspace.SSHConfigPath, s.workspace.SSHConfigIncludePath, s.log) } func (s *proxyClient) Stop(ctx context.Context, opt client.StopOptions) error { diff --git a/pkg/client/clientimplementation/workspace_client.go b/pkg/client/clientimplementation/workspace_client.go index 45b28720f..0809d6868 100644 --- a/pkg/client/clientimplementation/workspace_client.go +++ b/pkg/client/clientimplementation/workspace_client.go @@ -410,7 +410,7 @@ func (s *workspaceClient) Delete(ctx context.Context, opt client.DeleteOptions) } } - return DeleteWorkspaceFolder(s.workspace.Context, s.workspace.ID, s.workspace.SSHConfigPath, s.log) + return DeleteWorkspaceFolder(s.workspace.Context, s.workspace.ID, s.workspace.SSHConfigPath, s.workspace.SSHConfigIncludePath, s.log) } func (s *workspaceClient) isMachineRunning(ctx context.Context) (bool, error) { @@ -643,14 +643,22 @@ func DeleteMachineFolder(context, machineID string) error { return nil } -func DeleteWorkspaceFolder(context string, workspaceID string, sshConfigPath string, log log.Logger) error { +func DeleteWorkspaceFolder(context string, workspaceID string, sshConfigPath string, sshConfigIncludePath string, log log.Logger) error { path, err := ssh.ResolveSSHConfigPath(sshConfigPath) if err != nil { return err } sshConfigPath = path - err = ssh.RemoveFromConfig(workspaceID, sshConfigPath, log) + if sshConfigIncludePath != "" { + includePath, err := ssh.ResolveSSHConfigPath(sshConfigIncludePath) + if err != nil { + return err + } + sshConfigIncludePath = includePath + } + + err = ssh.RemoveFromConfig(workspaceID, sshConfigPath, sshConfigIncludePath, log) if err != nil { log.Errorf("Remove workspace '%s' from ssh config: %v", workspaceID, err) } diff --git a/pkg/config/context.go b/pkg/config/context.go index 00db7e603..9bb0e20ab 100644 --- a/pkg/config/context.go +++ b/pkg/config/context.go @@ -19,6 +19,7 @@ const ( ContextOptionDotfilesScript = "DOTFILES_SCRIPT" ContextOptionSSHAgentForwarding = "SSH_AGENT_FORWARDING" ContextOptionSSHConfigPath = "SSH_CONFIG_PATH" + ContextOptionSSHConfigIncludePath = "SSH_CONFIG_INCLUDE_PATH" ContextOptionAgentInjectTimeout = "AGENT_INJECT_TIMEOUT" ContextOptionRegistryCache = "REGISTRY_CACHE" ContextOptionSSHStrictHostKeyChecking = "SSH_STRICT_HOST_KEY_CHECKING" @@ -89,6 +90,10 @@ var ContextOptions = []ContextOption{ Name: ContextOptionSSHConfigPath, Description: "Specifies the path where the ssh config should be written to", }, + { + Name: ContextOptionSSHConfigIncludePath, + Description: "Specifies an alternate path where DevPod host entries should be written. Use this when your main SSH config is read-only (e.g., managed by Nix). Your main SSH config should have an Include directive pointing to this file.", + }, { Name: ContextOptionAgentInjectTimeout, Description: "Specifies the timeout to inject the agent", diff --git a/pkg/provider/workspace.go b/pkg/provider/workspace.go index 7d9c80d91..9a7cb43a0 100644 --- a/pkg/provider/workspace.go +++ b/pkg/provider/workspace.go @@ -70,6 +70,9 @@ type Workspace struct { // Path to the file where the SSH config to access the workspace is stored SSHConfigPath string `json:"sshConfigPath,omitempty"` + + // Path to an alternate file where DevPod entries are written (for read-only SSH configs) + SSHConfigIncludePath string `json:"sshConfigIncludePath,omitempty"` } type ProMetadata struct { diff --git a/pkg/ssh/config.go b/pkg/ssh/config.go index fd2e13804..28a5b4861 100644 --- a/pkg/ssh/config.go +++ b/pkg/ssh/config.go @@ -23,20 +23,25 @@ var ( MarkerEndPrefix = "# DevPod End " ) -func ConfigureSSHConfig(sshConfigPath, context, workspace, user, workdir string, gpgagent bool, devPodHome string, log log.Logger) error { - return configureSSHConfigSameFile(sshConfigPath, context, workspace, user, workdir, "", gpgagent, devPodHome, log) +func ConfigureSSHConfig(sshConfigPath, sshConfigIncludePath, context, workspace, user, workdir string, gpgagent bool, devPodHome string, log log.Logger) error { + return configureSSHConfigSameFile(sshConfigPath, sshConfigIncludePath, context, workspace, user, workdir, "", gpgagent, devPodHome, log) } -func configureSSHConfigSameFile(sshConfigPath, context, workspace, user, workdir, command string, gpgagent bool, devPodHome string, log log.Logger) error { +func configureSSHConfigSameFile(sshConfigPath, sshConfigIncludePath, context, workspace, user, workdir, command string, gpgagent bool, devPodHome string, log log.Logger) error { configLock.Lock() defer configLock.Unlock() - newFile, err := addHost(sshConfigPath, workspace+"."+"devpod", user, context, workspace, workdir, command, gpgagent, devPodHome) + targetPath := sshConfigPath + if sshConfigIncludePath != "" { + targetPath = sshConfigIncludePath + } + + newFile, err := addHost(targetPath, workspace+"."+"devpod", user, context, workspace, workdir, command, gpgagent, devPodHome) if err != nil { return fmt.Errorf("parse ssh config %w", err) } - return writeSSHConfig(sshConfigPath, newFile, log) + return writeSSHConfig(targetPath, newFile, log) } type DevPodSSHEntry struct { @@ -135,15 +140,24 @@ func addHostSection(config, execPath, host, user, context, workspace, workdir, c return strings.Join(lines, newLineSep), nil } -func GetUser(workspaceID string, sshConfigPath string) (string, error) { +func GetUser(workspaceID string, sshConfigPath string, sshConfigIncludePath string) (string, error) { path, err := ResolveSSHConfigPath(sshConfigPath) if err != nil { return "", fmt.Errorf("invalid ssh config path %w", err) } sshConfigPath = path + targetPath := sshConfigPath + if sshConfigIncludePath != "" { + includePath, err := ResolveSSHConfigPath(sshConfigIncludePath) + if err != nil { + return "", fmt.Errorf("invalid ssh config include path %w", err) + } + targetPath = includePath + } + user := "root" - _, err = transformHostSection(sshConfigPath, workspaceID+"."+"devpod", func(line string) string { + _, err = transformHostSection(targetPath, workspaceID+"."+"devpod", func(line string) string { splitted := strings.Split(strings.ToLower(strings.TrimSpace(line)), " ") if len(splitted) == 2 && splitted[0] == "user" { user = strings.Trim(splitted[1], "\"") @@ -158,16 +172,21 @@ func GetUser(workspaceID string, sshConfigPath string) (string, error) { return user, nil } -func RemoveFromConfig(workspaceID string, sshConfigPath string, log log.Logger) error { +func RemoveFromConfig(workspaceID string, sshConfigPath string, sshConfigIncludePath string, log log.Logger) error { configLock.Lock() defer configLock.Unlock() - newFile, err := removeFromConfig(sshConfigPath, workspaceID+"."+"devpod") + targetPath := sshConfigPath + if sshConfigIncludePath != "" { + targetPath = sshConfigIncludePath + } + + newFile, err := removeFromConfig(targetPath, workspaceID+"."+"devpod") if err != nil { return fmt.Errorf("parse ssh config %w", err) } - return writeSSHConfig(sshConfigPath, newFile, log) + return writeSSHConfig(targetPath, newFile, log) } func writeSSHConfig(path, content string, log log.Logger) error { diff --git a/pkg/workspace/delete.go b/pkg/workspace/delete.go index ef50601b2..e27282adb 100644 --- a/pkg/workspace/delete.go +++ b/pkg/workspace/delete.go @@ -36,7 +36,7 @@ func Delete(ctx context.Context, devPodConfig *config.Config, args []string, ign log.Errorf("Error retrieving workspace: %v", err) // delete workspace folder - err = clientimplementation.DeleteWorkspaceFolder(devPodConfig.DefaultContext, workspaceID, "", log) + err = clientimplementation.DeleteWorkspaceFolder(devPodConfig.DefaultContext, workspaceID, "", "", log) if err != nil { return "", err } @@ -52,7 +52,7 @@ func Delete(ctx context.Context, devPodConfig *config.Config, args []string, ign workspaceConfig := client.WorkspaceConfig() if !force && workspaceConfig.Imported { // delete workspace folder - err = clientimplementation.DeleteWorkspaceFolder(devPodConfig.DefaultContext, client.Workspace(), workspaceConfig.SSHConfigPath, log) + err = clientimplementation.DeleteWorkspaceFolder(devPodConfig.DefaultContext, client.Workspace(), workspaceConfig.SSHConfigPath, workspaceConfig.SSHConfigIncludePath, log) if err != nil { return "", err } @@ -138,7 +138,7 @@ func deleteSingleMachine(ctx context.Context, client client2.BaseWorkspaceClient } // delete workspace folder - err = clientimplementation.DeleteWorkspaceFolder(client.Context(), client.Workspace(), client.WorkspaceConfig().SSHConfigPath, log) + err = clientimplementation.DeleteWorkspaceFolder(client.Context(), client.Workspace(), client.WorkspaceConfig().SSHConfigPath, client.WorkspaceConfig().SSHConfigIncludePath, log) if err != nil { return false, err } diff --git a/pkg/workspace/list.go b/pkg/workspace/list.go index 47b8ef353..e8fff51b9 100644 --- a/pkg/workspace/list.go +++ b/pkg/workspace/list.go @@ -53,7 +53,7 @@ func List(ctx context.Context, devPodConfig *config.Config, skipPro bool, owner for _, localWorkspace := range localWorkspaces { if localWorkspace.IsPro() { if shouldDeleteLocalWorkspace(ctx, localWorkspace, proWorkspaceResults) { - err = clientimplementation.DeleteWorkspaceFolder(devPodConfig.DefaultContext, localWorkspace.ID, "", log) + err = clientimplementation.DeleteWorkspaceFolder(devPodConfig.DefaultContext, localWorkspace.ID, localWorkspace.SSHConfigPath, localWorkspace.SSHConfigIncludePath, log) if err != nil { log.Debugf("failed to delete local workspace %s: %v", localWorkspace.ID, err) } diff --git a/pkg/workspace/workspace.go b/pkg/workspace/workspace.go index fb13a4553..6d7a227c6 100644 --- a/pkg/workspace/workspace.go +++ b/pkg/workspace/workspace.go @@ -40,6 +40,7 @@ func Resolve( devContainerImage string, devContainerPath string, sshConfigPath string, + sshConfigIncludePath string, source *providerpkg.WorkspaceSource, uid string, changeLastUsed bool, @@ -64,6 +65,7 @@ func Resolve( desiredMachine, providerUserOptions, sshConfigPath, + sshConfigIncludePath, source, uid, changeLastUsed, @@ -144,7 +146,7 @@ func Get(ctx context.Context, devPodConfig *config.Config, args []string, change // check if we have no args if len(args) == 0 { - provider, workspace, machine, err = selectWorkspace(ctx, devPodConfig, changeLastUsed, "", owner, log) + provider, workspace, machine, err = selectWorkspace(ctx, devPodConfig, changeLastUsed, "", "", owner, log) if err != nil { return nil, err } @@ -190,6 +192,7 @@ func resolveWorkspace( desiredMachine string, providerUserOptions []string, sshConfigPath string, + sshConfigIncludePath string, source *providerpkg.WorkspaceSource, uid string, changeLastUsed bool, @@ -206,7 +209,7 @@ func resolveWorkspace( return loadExistingWorkspace(devPodConfig, workspace.ID, changeLastUsed, log) } - return selectWorkspace(ctx, devPodConfig, changeLastUsed, sshConfigPath, owner, log) + return selectWorkspace(ctx, devPodConfig, changeLastUsed, sshConfigPath, sshConfigIncludePath, owner, log) } // check if workspace already exists @@ -242,13 +245,14 @@ func resolveWorkspace( desiredMachine, providerUserOptions, sshConfigPath, + sshConfigIncludePath, source, isLocalPath, uid, log, ) if err != nil { - _ = clientimplementation.DeleteWorkspaceFolder(devPodConfig.DefaultContext, workspaceID, sshConfigPath, log) + _ = clientimplementation.DeleteWorkspaceFolder(devPodConfig.DefaultContext, workspaceID, sshConfigPath, sshConfigIncludePath, log) return nil, nil, nil, err } @@ -263,6 +267,7 @@ func createWorkspace( desiredMachine string, providerUserOptions []string, sshConfigPath string, + sshConfigIncludePath string, source *providerpkg.WorkspaceSource, isLocalPath bool, uid string, @@ -277,7 +282,7 @@ func createWorkspace( } // resolve workspace - workspace, err := resolveWorkspaceConfig(ctx, provider, devPodConfig, name, workspaceID, source, isLocalPath, sshConfigPath, uid) + workspace, err := resolveWorkspaceConfig(ctx, provider, devPodConfig, name, workspaceID, source, isLocalPath, sshConfigPath, sshConfigIncludePath, uid) if err != nil { return nil, nil, nil, err } @@ -400,6 +405,7 @@ func resolveWorkspaceConfig( source *providerpkg.WorkspaceSource, isLocalPath bool, sshConfigPath string, + sshConfigIncludePath string, uid string, ) (*providerpkg.Workspace, error) { now := types.Now() @@ -413,9 +419,10 @@ func resolveWorkspaceConfig( Provider: providerpkg.WorkspaceProviderConfig{ Name: defaultProvider.Config.Name, }, - CreationTimestamp: now, - LastUsedTimestamp: now, - SSHConfigPath: sshConfigPath, + CreationTimestamp: now, + LastUsedTimestamp: now, + SSHConfigPath: sshConfigPath, + SSHConfigIncludePath: sshConfigIncludePath, } // outside source set? @@ -550,7 +557,7 @@ func findWorkspace(ctx context.Context, devPodConfig *config.Config, args []stri return retWorkspace } -func selectWorkspace(ctx context.Context, devPodConfig *config.Config, changeLastUsed bool, sshConfigPath string, owner platform.OwnerFilter, log log.Logger) (*providerpkg.ProviderConfig, *providerpkg.Workspace, *providerpkg.Machine, error) { +func selectWorkspace(ctx context.Context, devPodConfig *config.Config, changeLastUsed bool, sshConfigPath string, sshConfigIncludePath string, owner platform.OwnerFilter, log log.Logger) (*providerpkg.ProviderConfig, *providerpkg.Workspace, *providerpkg.Machine, error) { if !terminal.IsTerminalIn { return nil, nil, nil, errProvideWorkspaceArg } @@ -601,6 +608,9 @@ func selectWorkspace(ctx context.Context, devPodConfig *config.Config, changeLas if workspace.SSHConfigPath == "" && sshConfigPath != "" { workspace.SSHConfigPath = sshConfigPath } + if workspace.SSHConfigIncludePath == "" && sshConfigIncludePath != "" { + workspace.SSHConfigIncludePath = sshConfigIncludePath + } workspace.Imported = true if err := providerpkg.SaveWorkspaceConfig(workspace); err != nil { return nil, nil, nil, fmt.Errorf("save workspace config for workspace \"%s\": %w", workspace.ID, err)