diff --git a/cmd/internal/agent_daemon.go b/cmd/internal/agent_daemon.go index 2ef6a5654..5dad5ac47 100644 --- a/cmd/internal/agent_daemon.go +++ b/cmd/internal/agent_daemon.go @@ -173,7 +173,7 @@ func (cmd *DaemonCmd) checkAndShutdown( latestActivity time.Time, workspace *provider2.AgentWorkspaceInfo, ) { - if cmd.ShutdownAction == config.ShutdownActionNone { + if cmd.effectiveShutdownAction(workspace) == config.ShutdownActionNone { return } @@ -201,6 +201,20 @@ func (cmd *DaemonCmd) checkAndShutdown( cmd.runShutdownCommand(ctx, workspace) } +// effectiveShutdownAction prefers the workspace's resolved config, falling back +// to the daemon's install-time flag when the workspace has none yet. +func (cmd *DaemonCmd) effectiveShutdownAction( + workspace *provider2.AgentWorkspaceInfo, +) string { + if workspace != nil && + workspace.LastDevContainerConfig != nil && + workspace.LastDevContainerConfig.Config != nil && + workspace.LastDevContainerConfig.Config.ShutdownAction != "" { + return workspace.LastDevContainerConfig.Config.ShutdownAction + } + return cmd.ShutdownAction +} + func (cmd *DaemonCmd) runShutdownCommand( ctx context.Context, workspace *provider2.AgentWorkspaceInfo, diff --git a/cmd/internal/agent_daemon_test.go b/cmd/internal/agent_daemon_test.go index 0e83476f9..75277f0e5 100644 --- a/cmd/internal/agent_daemon_test.go +++ b/cmd/internal/agent_daemon_test.go @@ -8,6 +8,7 @@ import ( "time" "github.com/devsy-org/devsy/pkg/agent" + "github.com/devsy-org/devsy/pkg/devcontainer/config" provider2 "github.com/devsy-org/devsy/pkg/provider" "github.com/devsy-org/devsy/pkg/types" "github.com/stretchr/testify/assert" @@ -89,6 +90,48 @@ func TestFindLatestActivity_PicksLatest(t *testing.T) { assert.Equal(t, recentTime, *activity) } +func workspaceWithShutdownAction(action string) *provider2.AgentWorkspaceInfo { + ws := &provider2.AgentWorkspaceInfo{Workspace: &provider2.Workspace{ID: "ws-test"}} + if action != "" { + ws.LastDevContainerConfig = &config.DevContainerConfigWithPath{ + Config: &config.DevContainerConfig{ + DevContainerConfigBase: config.DevContainerConfigBase{ShutdownAction: action}, + }, + } + } + return ws +} + +func TestEffectiveShutdownAction(t *testing.T) { + t.Run("prefers per-workspace config over flag", func(t *testing.T) { + cmd := &DaemonCmd{ShutdownAction: config.ShutdownActionStopContainer} + ws := workspaceWithShutdownAction(config.ShutdownActionNone) + assert.Equal(t, config.ShutdownActionNone, cmd.effectiveShutdownAction(ws)) + }) + + t.Run("falls back to flag when workspace has no config", func(t *testing.T) { + cmd := &DaemonCmd{ShutdownAction: config.ShutdownActionNone} + ws := workspaceWithShutdownAction("") + assert.Equal(t, config.ShutdownActionNone, cmd.effectiveShutdownAction(ws)) + }) + + t.Run("falls back to flag when config action is empty", func(t *testing.T) { + cmd := &DaemonCmd{ShutdownAction: config.ShutdownActionNone} + ws := &provider2.AgentWorkspaceInfo{ + Workspace: &provider2.Workspace{ID: "ws-test"}, + LastDevContainerConfig: &config.DevContainerConfigWithPath{ + Config: &config.DevContainerConfig{}, + }, + } + assert.Equal(t, config.ShutdownActionNone, cmd.effectiveShutdownAction(ws)) + }) + + t.Run("falls back to flag for nil workspace", func(t *testing.T) { + cmd := &DaemonCmd{ShutdownAction: config.ShutdownActionStopContainer} + assert.Equal(t, config.ShutdownActionStopContainer, cmd.effectiveShutdownAction(nil)) + }) +} + func TestEffectiveActivity(t *testing.T) { orig := activityFilePath t.Cleanup(func() { activityFilePath = orig }) diff --git a/cmd/internal/agentworkspace/up.go b/cmd/internal/agentworkspace/up.go index 8ce715a0d..7261d5854 100644 --- a/cmd/internal/agentworkspace/up.go +++ b/cmd/internal/agentworkspace/up.go @@ -162,6 +162,10 @@ func (cmd *UpCmd) up( return err } + // Persist so the daemon, started before the build resolved the config, can + // read the workspace's shutdownAction on the first up. + persistResolvedConfig(workspaceInfo, result) + // runner.Up can return (result, nil) where result carries a structured // Error forwarded from the inner container-setup step. Treat that as a // failure so the agent process exits non-zero and the host doesn't try @@ -747,6 +751,20 @@ func installDaemon(workspaceInfo *provider.AgentWorkspaceInfo) error { ) } +func persistResolvedConfig( + workspaceInfo *provider.AgentWorkspaceInfo, + result *config2.Result, +) { + if result == nil || result.Error != "" || result.DevContainerConfigWithPath == nil { + return + } + + workspaceInfo.LastDevContainerConfig = result.DevContainerConfigWithPath + if err := agent.PersistAgentWorkspaceInfo(workspaceInfo); err != nil { + log.Errorf("persist resolved devcontainer config: %v", err) + } +} + func downloadLocalFolder( ctx context.Context, workspaceDir string, diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go index da247b570..3c30ed296 100644 --- a/pkg/agent/agent.go +++ b/pkg/agent/agent.go @@ -319,6 +319,18 @@ func DeleteWorkspaceBusyFile(folder string) { _ = os.Remove(filepath.Join(folder, config.WorkspaceBusyFile)) } +// PersistAgentWorkspaceInfo writes the workspace info back to workspace.json +// under workspaceInfo.Origin. +func PersistAgentWorkspaceInfo(workspaceInfo *provider2.AgentWorkspaceInfo) error { + if workspaceInfo == nil || workspaceInfo.Origin == "" { + return errors.New("workspace origin is not set") + } + return writeWorkspaceInfo( + filepath.Join(workspaceInfo.Origin, provider2.WorkspaceConfigFile), + workspaceInfo, + ) +} + func writeWorkspaceInfo(file string, workspaceInfo *provider2.AgentWorkspaceInfo) error { // copy workspace info cloned := provider2.CloneAgentWorkspaceInfo(workspaceInfo) @@ -327,7 +339,7 @@ func writeWorkspaceInfo(file string, workspaceInfo *provider2.AgentWorkspaceInfo cloned.CLIOptions = provider2.CLIOptions{} // encode workspace info - encoded, err := json.Marshal(workspaceInfo) + encoded, err := json.Marshal(cloned) if err != nil { return err } diff --git a/pkg/agent/persist_test.go b/pkg/agent/persist_test.go new file mode 100644 index 000000000..a8904fe72 --- /dev/null +++ b/pkg/agent/persist_test.go @@ -0,0 +1,56 @@ +package agent + +import ( + "path/filepath" + "testing" + + "github.com/devsy-org/devsy/pkg/devcontainer/config" + provider2 "github.com/devsy-org/devsy/pkg/provider" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPersistAgentWorkspaceInfo_RoundTripsResolvedConfig(t *testing.T) { + dir := t.TempDir() + info := &provider2.AgentWorkspaceInfo{ + Origin: dir, + Workspace: &provider2.Workspace{ID: "ws-test"}, + LastDevContainerConfig: &config.DevContainerConfigWithPath{ + Config: &config.DevContainerConfig{ + DevContainerConfigBase: config.DevContainerConfigBase{ + ShutdownAction: config.ShutdownActionNone, + }, + }, + }, + } + + require.NoError(t, PersistAgentWorkspaceInfo(info)) + + got, err := ParseAgentWorkspaceInfo(filepath.Join(dir, provider2.WorkspaceConfigFile)) + require.NoError(t, err) + require.NotNil(t, got.LastDevContainerConfig) + require.NotNil(t, got.LastDevContainerConfig.Config) + assert.Equal(t, config.ShutdownActionNone, got.LastDevContainerConfig.Config.ShutdownAction) +} + +func TestPersistAgentWorkspaceInfo_RequiresOrigin(t *testing.T) { + assert.Error(t, PersistAgentWorkspaceInfo(&provider2.AgentWorkspaceInfo{})) + assert.Error(t, PersistAgentWorkspaceInfo(nil)) +} + +func TestPersistAgentWorkspaceInfo_DoesNotPersistCLIOptions(t *testing.T) { + dir := t.TempDir() + info := &provider2.AgentWorkspaceInfo{ + Origin: dir, + Workspace: &provider2.Workspace{ID: "ws-test"}, + CLIOptions: provider2.CLIOptions{DaemonInterval: "3s"}, + } + + require.NoError(t, PersistAgentWorkspaceInfo(info)) + + got, err := ParseAgentWorkspaceInfo(filepath.Join(dir, provider2.WorkspaceConfigFile)) + require.NoError(t, err) + assert.Equal(t, "ws-test", got.Workspace.ID) + assert.Empty(t, got.CLIOptions.DaemonInterval, "CLIOptions must not be persisted") + assert.Equal(t, "3s", info.CLIOptions.DaemonInterval, "caller's struct must not be mutated") +}