diff --git a/cmd/internal/agentworkspace/up.go b/cmd/internal/agentworkspace/up.go index ee855b876..09882255e 100644 --- a/cmd/internal/agentworkspace/up.go +++ b/cmd/internal/agentworkspace/up.go @@ -150,7 +150,7 @@ func (cmd *UpCmd) up( workspaceInfo *provider.AgentWorkspaceInfo, tunnelClient tunnel.TunnelClient, ) error { - result, err := cmd.devsyUp(ctx, workspaceInfo) + result, err := cmd.devsyUp(ctx, workspaceInfo, tunnelClient) if err != nil { errResult := &config2.Result{ Error: err.Error(), @@ -200,16 +200,18 @@ func (cmd *UpCmd) sendResult( func (cmd *UpCmd) devsyUp( ctx context.Context, workspaceInfo *provider.AgentWorkspaceInfo, + tunnelClient tunnel.TunnelClient, ) (*config2.Result, error) { runner, err := CreateRunner(ctx, workspaceInfo) if err != nil { return nil, err } + reporter := tunnelserver.NewTunnelStatusReporter(ctx, tunnelClient) return runner.Up(ctx, devcontainer.UpOptions{ CLIOptions: workspaceInfo.CLIOptions, RegistryCache: workspaceInfo.RegistryCache, - }, workspaceInfo.InjectTimeout) + }, workspaceInfo.InjectTimeout, reporter) } func CreateRunner( diff --git a/cmd/internal/container_tunnel.go b/cmd/internal/container_tunnel.go index b3fb4af1b..6553eabe3 100644 --- a/cmd/internal/container_tunnel.go +++ b/cmd/internal/container_tunnel.go @@ -19,6 +19,7 @@ import ( "github.com/devsy-org/devsy/pkg/flags/names" "github.com/devsy-org/devsy/pkg/log" provider2 "github.com/devsy-org/devsy/pkg/provider" + "github.com/devsy-org/devsy/pkg/status" "github.com/spf13/cobra" ) @@ -161,6 +162,7 @@ func StartContainer( ctx, devcontainer.UpOptions{NoBuild: true}, workspaceConfig.InjectTimeout, + status.Nop(), ) if err != nil { return result, err diff --git a/cmd/workspace/task.go b/cmd/workspace/task.go new file mode 100644 index 000000000..2ccf2c53c --- /dev/null +++ b/cmd/workspace/task.go @@ -0,0 +1,341 @@ +package workspace + +import ( + "context" + "encoding/json" + "fmt" + "os" + "time" + + "github.com/devsy-org/devsy/cmd/flags" + config2 "github.com/devsy-org/devsy/pkg/devcontainer/config" + cliflags "github.com/devsy-org/devsy/pkg/flags" + "github.com/devsy-org/devsy/pkg/flags/names" + "github.com/devsy-org/devsy/pkg/output" + "github.com/devsy-org/devsy/pkg/status" + "github.com/devsy-org/devsy/pkg/task" + "github.com/spf13/cobra" +) + +// NewTaskCmd builds the workspace task parent command. +func NewTaskCmd(globalFlags *flags.GlobalFlags) *cobra.Command { + taskCmd := &cobra.Command{ + Use: "task", + Short: "Manage background tasks (e.g. from 'up --detach')", + } + taskCmd.AddCommand(newTaskListCmd(globalFlags)) + taskCmd.AddCommand(newTaskGetCmd(globalFlags)) + taskCmd.AddCommand(newTaskLogsCmd(globalFlags)) + taskCmd.AddCommand(newTaskCancelCmd(globalFlags)) + taskCmd.AddCommand(newTaskRmCmd(globalFlags)) + return taskCmd +} + +type taskListCmd struct { + *flags.GlobalFlags +} + +func newTaskListCmd(globalFlags *flags.GlobalFlags) *cobra.Command { + cmd := &taskListCmd{GlobalFlags: globalFlags} + return &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List background tasks, most recently started first", + Args: cobra.NoArgs, + RunE: func(*cobra.Command, []string) error { + return cmd.run() + }, + } +} + +func (cmd *taskListCmd) run() error { + emitJSON, err := resolveEmitJSON(cmd.GlobalFlags) + if err != nil { + return err + } + + store, err := task.NewStore() + if err != nil { + return err + } + states, err := store.List() + if err != nil { + return err + } + + if emitJSON { + return json.NewEncoder(os.Stdout).Encode(states) + } + for _, s := range states { + _, _ = fmt.Fprintf(os.Stdout, "%s\t%s\t%s\t%s\n", s.ID, s.Status, s.Command, s.WorkspaceID) + } + return nil +} + +type taskGetCmd struct { + *flags.GlobalFlags +} + +func newTaskGetCmd(globalFlags *flags.GlobalFlags) *cobra.Command { + cmd := &taskGetCmd{GlobalFlags: globalFlags} + return &cobra.Command{ + Use: "get ", + Aliases: []string{"describe", "show"}, + Short: "Show a task's current status", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + return cmd.run(args[0]) + }, + } +} + +func (cmd *taskGetCmd) run(id string) error { + emitJSON, err := resolveEmitJSON(cmd.GlobalFlags) + if err != nil { + return err + } + store, err := task.NewStore() + if err != nil { + return err + } + state, err := store.Get(id) + if err != nil { + return err + } + return reportTaskState(state, emitJSON) +} + +type taskLogsCmd struct { + *flags.GlobalFlags + + Follow bool + Interval string +} + +func newTaskLogsCmd(globalFlags *flags.GlobalFlags) *cobra.Command { + cmd := &taskLogsCmd{GlobalFlags: globalFlags} + logsCmd := &cobra.Command{ + Use: "logs ", + Aliases: []string{"attach"}, + Short: "Show a task's status, or follow it until it finishes", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + return cmd.run(args[0]) + }, + } + cliflags.Add(logsCmd, + cliflags.Bool(&cmd.Follow, names.Follow, false, + "Poll until the task reaches a terminal state instead of reporting once"). + Shorthand("f"), + cliflags.String(&cmd.Interval, names.Interval, "500ms", + "Poll interval when --follow is set"), + ) + return logsCmd +} + +func (cmd *taskLogsCmd) run(id string) error { + emitJSON, err := resolveEmitJSON(cmd.GlobalFlags) + if err != nil { + return err + } + store, err := task.NewStore() + if err != nil { + return err + } + + if !cmd.Follow { + state, err := store.Get(id) + if err != nil { + return err + } + return reportTaskState(state, emitJSON) + } + + interval, err := time.ParseDuration(cmd.Interval) + if err != nil { + return fmt.Errorf("parse --interval: %w", err) + } + if interval <= 0 { + return fmt.Errorf("--interval must be positive, got %q", cmd.Interval) + } + return followTask(context.Background(), store, followTaskOptions{ + id: id, + interval: interval, + emitJSON: emitJSON, + }) +} + +type followTaskOptions struct { + id string + interval time.Duration + emitJSON bool +} + +func followTask(ctx context.Context, store *task.Store, opts followTaskOptions) error { + var last *task.State + ticker := time.NewTicker(opts.interval) + defer ticker.Stop() + + for { + state, err := store.Get(opts.id) + if err != nil { + return err + } + emitTaskTransition(last, state, opts.emitJSON) + last = state + + if state.Status.Terminal() { + return reportTaskState(state, opts.emitJSON) + } + + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + } + } +} + +func emitTaskTransition(last, current *task.State, emitJSON bool) { + if last != nil && last.Phase == current.Phase && last.Step == current.Step { + return + } + if current.Phase == "" { + return + } + event := status.Event{Phase: status.Phase(current.Phase), Step: current.Step, Started: true} + if emitJSON { + _ = config2.WriteStatusJSON(os.Stdout, event) + return + } + _, _ = fmt.Fprintf(os.Stdout, "task %s: %s\n", current.ID, current.Phase) +} + +type taskCancelCmd struct { + *flags.GlobalFlags +} + +func newTaskCancelCmd(globalFlags *flags.GlobalFlags) *cobra.Command { + cmd := &taskCancelCmd{GlobalFlags: globalFlags} + return &cobra.Command{ + Use: "cancel ", + Aliases: []string{"stop"}, + Short: "Stop a task's process and mark it failed", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + return cmd.run(args[0]) + }, + } +} + +func (cmd *taskCancelCmd) run(id string) error { + emitJSON, err := resolveEmitJSON(cmd.GlobalFlags) + if err != nil { + return err + } + store, err := task.NewStore() + if err != nil { + return err + } + if err := store.Open(id).Cancel(); err != nil { + return err + } + state, err := store.Get(id) + if err != nil { + return err + } + + // Report the raw state. Canceling is not itself a failure. + if emitJSON { + return json.NewEncoder(os.Stdout).Encode(state) + } + _, _ = fmt.Fprintf(os.Stdout, "task %s: canceled\n", state.ID) + return nil +} + +type taskRmCmd struct { + *flags.GlobalFlags + + Force bool +} + +func newTaskRmCmd(globalFlags *flags.GlobalFlags) *cobra.Command { + cmd := &taskRmCmd{GlobalFlags: globalFlags} + rmCmd := &cobra.Command{ + Use: "rm ", + Aliases: []string{"delete", "remove"}, + Short: "Delete a finished task's state", + Args: cobra.ExactArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + return cmd.run(args[0]) + }, + } + cliflags.Add(rmCmd, + cliflags.Bool(&cmd.Force, names.Force, false, + "Stop the task first if it's still pending or running, then delete"), + ) + return rmCmd +} + +func (cmd *taskRmCmd) run(id string) error { + store, err := task.NewStore() + if err != nil { + return err + } + if cmd.Force { + if err := store.Open(id).Cancel(); err != nil { + return err + } + } + return store.Delete(id, cmd.Force) +} + +func resolveEmitJSON(g *flags.GlobalFlags) (bool, error) { + mode, err := output.ResolveMode(g.ResultFormat) + if err != nil { + return false, err + } + return mode == output.ModeJSON, nil +} + +func reportTaskState(state *task.State, emitJSON bool) error { + if emitJSON { + return reportTaskStateJSON(state) + } + + _, _ = fmt.Fprintf(os.Stdout, "task %s: %s\n", state.ID, state.Status) + if state.Status == task.StatusFailed { + return fmt.Errorf("%s", state.Error) + } + return nil +} + +func reportTaskStateJSON(state *task.State) error { + switch state.Status { + case task.StatusFailed: + if err := config2.WriteErrorJSON(os.Stdout, state.Error); err != nil { + return err + } + return fmt.Errorf("%s", state.Error) + case task.StatusSucceeded: + return config2.WriteResultJSON(os.Stdout, resultEnvelopeFrom(state)) + default: + return config2.WriteStatusJSON(os.Stdout, status.Event{ + Phase: status.Phase(state.Phase), + Step: state.Step, + Started: true, + }) + } +} + +func resultEnvelopeFrom(state *task.State) config2.ResultEnvelope { + if state.Result == nil { + return config2.ResultEnvelope{} + } + return config2.ResultEnvelope{ + ContainerID: config2.GetContainerID(state.Result), + RemoteUser: config2.GetRemoteUser(state.Result), + Warnings: state.Result.HostWarnings, + Recovery: state.Result.RecoveryContainer, + } +} diff --git a/cmd/workspace/task_test.go b/cmd/workspace/task_test.go new file mode 100644 index 000000000..f59d304e4 --- /dev/null +++ b/cmd/workspace/task_test.go @@ -0,0 +1,40 @@ +package workspace + +import ( + "testing" + + "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/devsy-org/devsy/pkg/task" +) + +const testResultContainerID = "abc123" + +func TestResultEnvelopeFrom_NilResult(t *testing.T) { + got := resultEnvelopeFrom(&task.State{}) + if got.ContainerID != "" || got.RemoteUser != "" || got.Recovery || len(got.Warnings) != 0 { + t.Errorf("expected zero envelope, got %+v", got) + } +} + +func TestResultEnvelopeFrom_PopulatedResult(t *testing.T) { + state := &task.State{ + Result: &config.Result{ + HostWarnings: []string{"warn"}, + RecoveryContainer: true, + ContainerDetails: &config.ContainerDetails{ + ID: testResultContainerID, + }, + }, + } + + got := resultEnvelopeFrom(state) + if got.ContainerID != testResultContainerID { + t.Errorf("ContainerID = %q, want %q", got.ContainerID, testResultContainerID) + } + if !got.Recovery { + t.Error("expected Recovery = true") + } + if len(got.Warnings) != 1 || got.Warnings[0] != "warn" { + t.Errorf("Warnings = %v", got.Warnings) + } +} diff --git a/cmd/workspace/up/agent.go b/cmd/workspace/up/agent.go index 5df7d3222..cbb603931 100644 --- a/cmd/workspace/up/agent.go +++ b/cmd/workspace/up/agent.go @@ -99,6 +99,7 @@ func (cmd *UpCmd) devsyUpProxy( true, client.WorkspaceConfig(), tunnelserver.WithGitToken(cmd.GitToken), + tunnelserver.WithStatusReporter(cmd.reporter()), ) if err != nil { return nil, fmt.Errorf("run tunnel machine: %w", err) @@ -137,6 +138,7 @@ func (cmd *UpCmd) devsyUpDaemon( return client.Up(ctx, client2.UpOptions{ CLIOptions: baseOptions, Debug: cmd.Debug, + Reporter: cmd.reporter(), }) } @@ -181,6 +183,7 @@ func (cmd *UpCmd) devsyUpMachine( AgentCommand: "up", TunnelOptions: []tunnelserver.Option{ tunnelserver.WithPlatformOptions(&cmd.Platform), + tunnelserver.WithStatusReporter(cmd.reporter()), }, }, ) @@ -230,6 +233,7 @@ func (cmd *UpCmd) devsyUpMachineSSH( client.AgentInjectDockerCredentials(cmd.CLIOptions), client.WorkspaceConfig(), tunnelserver.WithGitToken(cmd.GitToken), + tunnelserver.WithStatusReporter(cmd.reporter()), ) }, }) diff --git a/cmd/workspace/up/detach.go b/cmd/workspace/up/detach.go new file mode 100644 index 000000000..89b1cd875 --- /dev/null +++ b/cmd/workspace/up/detach.go @@ -0,0 +1,130 @@ +package up + +import ( + "fmt" + "os" + "os/exec" + "strings" + + "github.com/devsy-org/devsy/pkg/command" + config2 "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/devsy-org/devsy/pkg/flags/names" + "github.com/devsy-org/devsy/pkg/output" + "github.com/devsy-org/devsy/pkg/task" +) + +// runDetached submits this invocation as a background task and returns +// immediately. +func (cmd *UpCmd) runDetached(args []string) error { + store, err := task.NewStore() + if err != nil { + return err + } + t, err := store.Create(task.CreateOptions{ + Command: "up", + WorkspaceID: cmd.detachWorkspaceLabel(args), + }) + if err != nil { + return err + } + + if err := launchDetached(t.ID()); err != nil { + _ = t.Fail(err) + return fmt.Errorf("launch detached up: %w", err) + } + + mode, err := output.ResolveMode(cmd.ResultFormat) + if err != nil { + return err + } + if mode == output.ModeJSON { + return config2.WriteTaskJSON(cmd.stdout(), t.ID()) + } + _, err = fmt.Fprintf(cmd.stdout(), + "Submitted task %s. Poll with 'workspace task get %s' or 'workspace task logs %s -f'.\n", + t.ID(), t.ID(), t.ID()) + return err +} + +func launchDetached(taskID string) error { + execPath, err := os.Executable() + if err != nil { + return err + } + + args := append(detachedArgs(os.Args[1:]), names.Flag(names.TaskID), taskID) + return command.StartBackground("devsy-up-"+taskID, func() (*exec.Cmd, error) { + return &exec.Cmd{ + Path: execPath, + Args: append([]string{execPath}, args...), + Env: os.Environ(), + Dir: wd(), + }, nil + }) +} + +// detachedArgs strips --detach so it isn't duplicated alongside --task-id. +func detachedArgs(args []string) []string { + detachFlag := names.Flag(names.Detach) + out := make([]string, 0, len(args)) + for _, a := range args { + if a == detachFlag || strings.HasPrefix(a, detachFlag+"=") || a == "-d" { + continue + } + out = append(out, a) + } + return out +} + +// detachWorkspaceLabel is a best-effort label for task list. +func (cmd *UpCmd) detachWorkspaceLabel(args []string) string { + if cmd.ID != "" { + return cmd.ID + } + if len(args) > 0 { + return args[0] + } + return "" +} + +func wd() string { + dir, err := os.Getwd() + if err != nil { + return "" + } + return dir +} + +// openTask returns nil when this invocation was not launched via detach. +func (cmd *UpCmd) openTask() (*task.Task, error) { + if cmd.taskID == "" { + return nil, nil + } + store, err := task.NewStore() + if err != nil { + return nil, err + } + t := store.Open(cmd.taskID) + // obtain the worker lock first. + if err := t.HoldWorkerLock(); err != nil { + return nil, err + } + if err := t.SetPID(os.Getpid()); err != nil { + return nil, err + } + return t, nil +} + +func failTask(t *task.Task, err error) { + if t == nil { + return + } + _ = t.Fail(err) +} + +func succeedTask(t *task.Task, result *config2.Result) { + if t == nil { + return + } + _ = t.Succeed(result) +} diff --git a/cmd/workspace/up/detach_test.go b/cmd/workspace/up/detach_test.go new file mode 100644 index 000000000..965d7b555 --- /dev/null +++ b/cmd/workspace/up/detach_test.go @@ -0,0 +1,35 @@ +package up + +import ( + "reflect" + "testing" +) + +const ( + testRepoArg = "myrepo" + testDebugFlag = "--debug" +) + +func TestDetachedArgs_StripsDetachFlag(t *testing.T) { + got := detachedArgs([]string{testRepoArg, "--detach", testDebugFlag}) + want := []string{testRepoArg, testDebugFlag} + if !reflect.DeepEqual(got, want) { + t.Errorf("detachedArgs() = %v, want %v", got, want) + } +} + +func TestDetachedArgs_StripsDetachEqualsValue(t *testing.T) { + got := detachedArgs([]string{testRepoArg, "--detach=true", testDebugFlag}) + want := []string{testRepoArg, testDebugFlag} + if !reflect.DeepEqual(got, want) { + t.Errorf("detachedArgs() = %v, want %v", got, want) + } +} + +func TestDetachedArgs_NoDetachFlagIsUnchanged(t *testing.T) { + got := detachedArgs([]string{testRepoArg, testDebugFlag}) + want := []string{testRepoArg, testDebugFlag} + if !reflect.DeepEqual(got, want) { + t.Errorf("detachedArgs() = %v, want %v", got, want) + } +} diff --git a/cmd/workspace/up/status.go b/cmd/workspace/up/status.go new file mode 100644 index 000000000..c325aa70a --- /dev/null +++ b/cmd/workspace/up/status.go @@ -0,0 +1,64 @@ +package up + +import ( + "io" + "sync" + + config2 "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/devsy-org/devsy/pkg/log" + "github.com/devsy-org/devsy/pkg/status" +) + +// newStatusReporter drives `up`'s progress output. +func newStatusReporter(emitJSON bool, out io.Writer) status.Reporter { + if emitJSON { + return &jsonStatusReporter{out: out} + } + return plainStatusReporter{} +} + +// jsonStatusReporter serializes write events. +type jsonStatusReporter struct { + mu sync.Mutex + out io.Writer +} + +func (r *jsonStatusReporter) Report(e status.Event) { + r.mu.Lock() + defer r.mu.Unlock() + _ = config2.WriteStatusJSON(r.out, e) +} + +type plainStatusReporter struct{} + +func (plainStatusReporter) Report(e status.Event) { + switch { + case e.Phase == status.PhaseFailed: + log.Errorf("up: phase %q failed: %s", e.Step, e.Err) + case e.Started && e.Step != "": + log.Infof("up: %s: %s", phaseLabel(e.Phase), e.Step) + case e.Started: + log.Infof("up: %s", phaseLabel(e.Phase)) + case e.Phase == status.PhaseReady: + log.Infof("up: %s", phaseLabel(e.Phase)) + } +} + +var phaseLabels = map[status.Phase]string{ + status.PhaseCloningRepository: "cloning repository", + status.PhaseResolvingConfig: "resolving devcontainer config", + status.PhaseInitializeCommand: "running initializeCommand", + status.PhaseBuildingImage: "building image", + status.PhaseStartingContainer: "starting container", + status.PhaseInjectingAgent: "injecting agent", + status.PhaseRunningLifecycleHook: "running lifecycle hooks", + status.PhaseWaitingFor: "waiting for readiness", + status.PhaseReady: "ready", +} + +func phaseLabel(p status.Phase) string { + if label, ok := phaseLabels[p]; ok { + return label + } + return string(p) +} diff --git a/cmd/workspace/up/up.go b/cmd/workspace/up/up.go index 326ac992c..f74dac58a 100644 --- a/cmd/workspace/up/up.go +++ b/cmd/workspace/up/up.go @@ -19,6 +19,7 @@ import ( "github.com/devsy-org/devsy/pkg/log" "github.com/devsy-org/devsy/pkg/output" provider2 "github.com/devsy-org/devsy/pkg/provider" + "github.com/devsy-org/devsy/pkg/status" "github.com/devsy-org/devsy/pkg/telemetry" "github.com/devsy-org/devsy/pkg/util" "github.com/devsy-org/devsy/pkg/workspace" @@ -53,9 +54,14 @@ type UpCmd struct { // Read via Changed() so unset is distinguishable from explicit false. pullFromInsideContainerFlag bool - + // See cmd.runDetached. + Detach bool + // Set only on the detached child re-exec. + taskID string // Out receives result/error JSON envelopes; nil falls back to os.Stdout. Out io.Writer + // Set at the start of Run; nil until then. + statusReporter status.Reporter } // Options is the structured input form of the up command. @@ -215,20 +221,51 @@ func (cmd *UpCmd) Run( emitJSON := mode == output.ModeJSON out := cmd.stdout() + cmd.statusReporter = newStatusReporter(emitJSON, out) + + t, err := cmd.openTask() + if err != nil { + return reportErr(err, emitJSON, out) + } + if t != nil { + cmd.statusReporter = status.Tee(cmd.statusReporter, t.Reporter()) + if err := t.SetWorkspaceID(client.Workspace()); err != nil { + failTask(t, err) + return reportErr(err, emitJSON, out) + } + } + wctx, err := cmd.executeDevsyUp(ctx, devsyConfig, client) if err != nil { + failTask(t, err) return reportErr(err, emitJSON, out) } if wctx == nil || cmd.Prebuild { + succeedTask(t, nil) return nil // Platform mode or prebuild-only run. } - return cmd.finalizeUp(ctx, &finalizeUpArgs{ + + err = cmd.finalizeUp(ctx, &finalizeUpArgs{ devsyConfig: devsyConfig, client: client, wctx: wctx, emitJSON: emitJSON, out: out, }) + if err != nil { + failTask(t, err) + return err + } + succeedTask(t, wctx.result) + return nil +} + +// reporter falls back to a no-op when Run hasn't set one yet. +func (cmd *UpCmd) reporter() status.Reporter { + if cmd.statusReporter == nil { + return status.Nop() + } + return cmd.statusReporter } func (cmd *UpCmd) checkExtraDevContainerProvider(client client2.BaseWorkspaceClient) error { @@ -349,6 +386,9 @@ func (cmd *UpCmd) execute(cobraCmd *cobra.Command, args []string) error { if err := cmd.validate(); err != nil { return err } + if cmd.Detach && cmd.taskID == "" { + return cmd.runDetached(args) + } cmd.applyPullFromInsideContainerOverride(cobraCmd) devsyConfig, err := config.LoadConfig(cmd.Context, cmd.Provider) if err != nil { diff --git a/cmd/workspace/up/up_flags.go b/cmd/workspace/up/up_flags.go index ea4131309..dd6b0a2a2 100644 --- a/cmd/workspace/up/up_flags.go +++ b/cmd/workspace/up/up_flags.go @@ -251,6 +251,12 @@ func (cmd *UpCmd) registerWorkspaceFlags(upCmd *cobra.Command) { false, "Clone the source inside the container instead of bind-mounting from the host (unset = auto-detect)", ), + flags.Bool(&cmd.Detach, names.Detach, false, + "Submit the workspace for provisioning and return immediately; "+ + "poll progress with 'workspace task get ' or 'workspace task logs -f'"). + Shorthand("d"), + flags.String(&cmd.taskID, names.TaskID, "", + "Internal: report progress into this task ID instead of stdout").Hidden(), ) } diff --git a/cmd/workspace/workspace.go b/cmd/workspace/workspace.go index 4152bc894..6d91765e4 100644 --- a/cmd/workspace/workspace.go +++ b/cmd/workspace/workspace.go @@ -29,5 +29,6 @@ func NewWorkspaceCmd(globalFlags *flags.GlobalFlags) *cobra.Command { cmd.AddCommand(NewImportCmd(globalFlags)) cmd.AddCommand(NewPingCmd(globalFlags)) cmd.AddCommand(NewTroubleshootCmd(globalFlags)) + cmd.AddCommand(NewTaskCmd(globalFlags)) return cmd } diff --git a/desktop/e2e/fixtures/mock-devsy.cjs b/desktop/e2e/fixtures/mock-devsy.cjs index 4458c2905..67d13feda 100755 --- a/desktop/e2e/fixtures/mock-devsy.cjs +++ b/desktop/e2e/fixtures/mock-devsy.cjs @@ -75,6 +75,7 @@ function defaultState() { context: "default", }, ], + tasks: {}, } } @@ -92,6 +93,7 @@ function saveState(state) { } const state = loadState() +state.tasks = state.tasks || {} const rawArgs = process.argv.slice(2) @@ -190,17 +192,8 @@ function handleSsh() { process.exit(0) } -function handleUp(args) { - const { positional, idFlag, providerFlag, ideFlag } = parseArgs(args) - const source = positional[0] - out("Resolving source...") - out("Pulling image...") - out("Starting workspace...") - out("Workspace ready.") - const wsId = - idFlag || - (source ? source.split("/").pop().replace(".git", "") : "") || - "workspace" +// Adds a completed workspace to state. +function materializeWorkspace(wsId, source, providerFlag, ideFlag) { state.workspaces.push({ id: wsId, uid: `ws-${Date.now()}`, @@ -213,9 +206,126 @@ function handleUp(args) { context: "default", }) saveState(state) +} + +function handleUp(args) { + const { positional, idFlag, providerFlag, ideFlag } = parseArgs(args) + const source = positional[0] + const wsId = + idFlag || + (source ? source.split("/").pop().replace(".git", "") : "") || + "workspace" + + // submits a background task instead of running to completion. + if (args.includes("--detach")) { + const taskId = `task-${Date.now()}` + state.tasks[taskId] = { + id: taskId, + status: "pending", + source, + wsId, + providerFlag, + ideFlag, + } + saveState(state) + out({ kind: "task", id: taskId }) + process.exit(0) + return + } + + out("Resolving source") + out("Pulling image") + out("Starting workspace") + out("Workspace ready") + materializeWorkspace(wsId, source, providerFlag, ideFlag) process.exit(0) } +// Handlers for `workspace task `, backing the up --detach flow above. +function handleTaskList() { + out(Object.values(state.tasks)) +} + +function handleTaskGet(args) { + const t = state.tasks[args[0]] + if (!t) { + process.stderr.write(`mock-devsy: task not found: ${args[0]}\n`) + process.exit(1) + return + } + out(t) +} + +function handleTaskLogs(args) { + const t = state.tasks[args[0]] + if (!t) { + process.stderr.write(`mock-devsy: task not found: ${args[0]}\n`) + process.exit(1) + return + } + + out("Resolving source") + out("Pulling image") + out("Starting workspace") + out("Workspace ready") + materializeWorkspace(t.wsId, t.source, t.providerFlag, t.ideFlag) + t.status = "succeeded" + saveState(state) + + // Single-line NDJSON result envelope. + out( + JSON.stringify({ + kind: "result", + outcome: "success", + containerId: `mock-container-${t.wsId}`, + remoteUser: "vscode", + remoteWorkspaceFolder: `/workspaces/${t.wsId}`, + }), + ) + process.exit(0) +} + +function handleTaskCancel(args) { + const t = state.tasks[args[0]] + if (t) { + t.status = "canceled" + saveState(state) + } + out({}) +} + +function handleTaskRm(args) { + delete state.tasks[args[0]] + saveState(state) + out({}) +} + +function handleTask(args) { + const verb = args[0] + const rest = args.slice(1) + const handlers = { + list: () => handleTaskList(), + ls: () => handleTaskList(), + get: () => handleTaskGet(rest), + describe: () => handleTaskGet(rest), + show: () => handleTaskGet(rest), + logs: () => handleTaskLogs(rest), + attach: () => handleTaskLogs(rest), + cancel: () => handleTaskCancel(rest), + stop: () => handleTaskCancel(rest), + rm: () => handleTaskRm(rest), + delete: () => handleTaskRm(rest), + remove: () => handleTaskRm(rest), + } + const handler = handlers[verb] + if (!handler) { + process.stderr.write(`mock-devsy: unknown task subcommand '${verb}'\n`) + process.exit(2) + return + } + handler() +} + function handleStop(args) { const { positional } = parseArgs(args) const wsId = positional[0] @@ -267,6 +377,7 @@ const workspaceHandlers = { status: handleStatus, ssh: handleSsh, up: handleUp, + task: handleTask, stop: handleStop, delete: handleDelete, rename: handleRename, diff --git a/desktop/e2e/workspaces.e2e.ts b/desktop/e2e/workspaces.e2e.ts index c223c6ea9..f8198f4d4 100644 --- a/desktop/e2e/workspaces.e2e.ts +++ b/desktop/e2e/workspaces.e2e.ts @@ -155,8 +155,8 @@ test.describe.serial("Create Workspace Wizard", () => { // The review step's primary button is labeled "Launch" await dialog.getByRole("button", { name: /^launch$/i }).click() - // Mock CLI streams: "Resolving source...", "Pulling image...", - // "Starting workspace...", "Workspace ready." + // Mock CLI streams: "Resolving source", "Pulling image", + // "Starting workspace", "Workspace ready." await expect(dialog).toContainText(/resolving|pulling|starting|ready/i, { timeout: 10000, }) diff --git a/desktop/src/main/__tests__/ipc-up-tasks.test.ts b/desktop/src/main/__tests__/ipc-up-tasks.test.ts new file mode 100644 index 000000000..560362159 --- /dev/null +++ b/desktop/src/main/__tests__/ipc-up-tasks.test.ts @@ -0,0 +1,264 @@ +// @vitest-environment node +import { EventEmitter } from "node:events" +import { beforeEach, describe, expect, it, vi } from "vitest" + +const handlers = new Map unknown>() + +vi.mock("electron", () => ({ + app: { getPath: () => "/tmp", getVersion: () => "0.0.0" }, + dialog: {}, + ipcMain: { + handle: (channel: string, fn: (...args: unknown[]) => unknown) => { + handlers.set(channel, fn) + }, + on: () => undefined, + }, +})) + +vi.mock("../analytics.js", () => ({ + hashWorkspaceRef: (v: string) => v, + trackEvent: () => undefined, +})) + +const { registerIpcHandlers } = await import("../ipc.js") + +function invokeUp(workspaceId: string): Promise { + const handler = handlers.get("workspace_up") + if (!handler) throw new Error("workspace_up not registered") + return handler({}, { source: workspaceId, workspaceId }) as Promise +} + +function invokeStop(workspaceId: string): Promise { + const handler = handlers.get("workspace_stop") + if (!handler) throw new Error("workspace_stop not registered") + return handler({}, { workspaceId }) as Promise +} + +/** A child that reports itself still alive, so cancel must await its exit. */ +function fakeChild() { + const child = new EventEmitter() as EventEmitter & { + exitCode: number | null + signalCode: string | null + kill: (signal?: string) => void + } + child.exitCode = null + child.signalCode = null + child.kill = () => { + setTimeout(() => { + child.exitCode = 0 + child.emit("close") + }, 0) + } + return child +} + +function setup( + overrides: { + run?: (args: string[]) => Promise + failStreaming?: Error + } = {}, +) { + const calls: string[][] = [] + const cli = { + run: vi.fn(async (args: string[]) => { + calls.push(args) + if (overrides.run) return overrides.run(args) + if (args.includes("--detach")) return { kind: "task", id: "task-1" } + return {} + }), + runStreaming: vi.fn( + async ( + _args: string[], + onLine: (line: string, stream: "stdout" | "stderr") => void, + onExit: (code: number, cliError?: unknown) => void, + ) => { + if (overrides.failStreaming) throw overrides.failStreaming + stream = { onLine, onExit } + return fakeChild() + }, + ), + cancelFor: vi.fn(async () => undefined), + } + let stream: { + onLine: (line: string, s: "stdout" | "stderr") => void + onExit: (code: number, cliError?: unknown) => void + } | null = null + const sent: Array<{ channel: string; payload: Record }> = [] + const win = { + webContents: { + send: (channel: string, payload: Record) => { + sent.push({ channel, payload }) + }, + }, + isDestroyed: () => false, + } + const deps = { + cli, + state: { + workspaceContext: () => "ctx", + providerList: () => [], + }, + logStore: { + createLogFile: () => "/tmp/log.txt", + appendLog: () => true, + closeLog: async () => undefined, + onDrain: async () => undefined, + }, + pty: { cancelFor: vi.fn(async () => undefined) }, + getMainWindow: () => win, + } + // biome-ignore lint/suspicious/noExplicitAny: partial test doubles + const api = registerIpcHandlers(deps as any) + return { cli, calls, api, sent, stream: () => stream } +} + +function statusEnvelope(phase: string, step?: string) { + return JSON.stringify({ + kind: "status", + pipeline: "workspace_up", + phase, + ...(step ? { step } : {}), + started: true, + }) +} + +describe("workspace_up detached task tracking", () => { + beforeEach(() => { + handlers.clear() + vi.clearAllMocks() + }) + + it("cancels the prior task before submitting a replacement", async () => { + const { calls } = setup() + + await invokeUp("ws-1") + await invokeUp("ws-1") + + const cancels = calls.filter((a) => a.includes("cancel")) + expect(cancels).toEqual([["workspace", "task", "cancel", "task-1"]]) + }) + + it("serializes concurrent submissions so neither task is left orphaned", async () => { + let seq = 0 + const { calls } = setup({ + run: async (args) => { + if (args.includes("--detach")) { + // Yield so an unserialized handler would interleave here. + await new Promise((r) => setTimeout(r, 5)) + seq += 1 + return { kind: "task", id: `task-${seq}` } + } + return {} + }, + }) + + await Promise.all([invokeUp("ws-1"), invokeUp("ws-1")]) + + const cancels = calls.filter((a) => a.includes("cancel")) + expect(cancels).toEqual([["workspace", "task", "cancel", "task-1"]]) + }) + + it("keeps the task cancellable when cancellation fails", async () => { + let failCancel = true + const { calls } = setup({ + run: async (args) => { + if (args.includes("cancel")) { + if (failCancel) throw new Error("cancel boom") + return {} + } + if (args.includes("--detach")) return { kind: "task", id: "task-1" } + return {} + }, + }) + + await invokeUp("ws-1") + + await invokeUp("ws-1") + expect(calls.filter((a) => a.includes("--detach"))).toHaveLength(1) + + failCancel = false + await invokeStop("ws-1") + const cancels = calls.filter((a) => a.includes("cancel")) + expect(cancels).toEqual([ + ["workspace", "task", "cancel", "task-1"], + ["workspace", "task", "cancel", "task-1"], + ]) + }) + + it("forwards status envelopes as workspace-status events", async () => { + const { sent, stream } = setup() + await invokeUp("ws-1") + + stream()?.onLine(statusEnvelope("building_image"), "stdout") + + const statuses = sent.filter((s) => s.channel === "workspace-status") + expect(statuses).toHaveLength(1) + expect(statuses[0].payload).toMatchObject({ + workspaceId: "ws-1", + phase: "building_image", + started: true, + }) + }) + + it("does not treat stderr lines as envelopes", async () => { + const { sent, stream } = setup() + await invokeUp("ws-1") + + stream()?.onLine(statusEnvelope("building_image"), "stderr") + + expect(sent.filter((s) => s.channel === "workspace-status")).toHaveLength(0) + }) + + it("releases the task on a result envelope, before the exit callback", async () => { + const { calls, stream } = setup() + await invokeUp("ws-1") + + stream()?.onLine( + JSON.stringify({ kind: "result", outcome: "success" }), + "stdout", + ) + stream()?.onExit(0) + await invokeUp("ws-1") + + expect(calls.filter((a) => a.includes("cancel"))).toEqual([]) + }) + + it("releases the task on an error envelope", async () => { + const { calls, stream } = setup() + await invokeUp("ws-1") + + stream()?.onLine( + JSON.stringify({ kind: "error", outcome: "error", message: "boom" }), + "stdout", + ) + stream()?.onExit(1) + await invokeUp("ws-1") + + expect(calls.filter((a) => a.includes("cancel"))).toEqual([]) + }) + + it("keeps the task cancellable when the follower fails to start", async () => { + // The task is already submitted at this point, so losing the id here would + // orphan a running workspace up with nothing left to cancel it by. + const { calls } = setup({ failStreaming: new Error("spawn ENOENT") }) + + await expect(invokeUp("ws-1")).resolves.toBeTruthy() + + await invokeStop("ws-1") + expect(calls.filter((a) => a.includes("cancel"))).toEqual([ + ["workspace", "task", "cancel", "task-1"], + ]) + }) + + it("keeps the task registered when the follower exits without an envelope", async () => { + const { calls, stream } = setup() + await invokeUp("ws-1") + + stream()?.onExit(1, { code: "boom", message: "follower died" }) + await invokeUp("ws-1") + + expect(calls.filter((a) => a.includes("cancel"))).toEqual([ + ["workspace", "task", "cancel", "task-1"], + ]) + }) +}) diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index 127105aa0..191867971 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -7,6 +7,7 @@ import { promisify } from "node:util" import type { BrowserWindow } from "electron" import { app, dialog, ipcMain } from "electron" import type { CLIError } from "../shared/cli-error.js" +import { parseCliEnvelope } from "../shared/cli-error.js" import { hashWorkspaceRef, trackEvent } from "./analytics.js" import { loadCatalog } from "./image-catalog.js" import type { CliRunner } from "./cli.js" @@ -165,14 +166,52 @@ export function registerIpcHandlers(deps: IpcDependencies): { string, import("node:child_process").ChildProcess >() + // Maps workspaceId -> task id for still-running `up --detach` submissions. + const activeUpTasks = new Map() + // Serializes the cancel/submit/register sequence per workspace. + const upSubmitChains = new Map>() /** - * Terminate every desktop-spawned process tied to a workspace and wait for - * them to actually exit. Called before stop/delete so destructive CLI runs - * don't race with in-flight children that are still appending to the - * workspace's log directory. + * Run fn after any prior invocation for the same workspace has settled. + * Concurrent `up` submissions would otherwise both pass the cancel step + * before either registers its task, leaving the first one running with no + * map entry to cancel it by. */ - async function quiesceWorkspace(workspaceId: string): Promise { + function serializePerWorkspace( + workspaceId: string, + fn: () => Promise, + ): Promise { + // Chain entries never reject, so a failed run doesn't poison the queue. + const prev = upSubmitChains.get(workspaceId) ?? Promise.resolve() + const next = prev.then(fn) + const settled = next.then( + () => undefined, + () => undefined, + ) + upSubmitChains.set(workspaceId, settled) + void settled.then(() => { + if (upSubmitChains.get(workspaceId) === settled) { + upSubmitChains.delete(workspaceId) + } + }) + return next + } + + /** + * Cancel a workspace's in-flight `up` task and terminate its status-follow + * child, awaiting exit so the handle is never dropped while the process is + * still alive (which would orphan it beyond the reach of any later kill). + */ + async function cancelActiveUp(workspaceId: string): Promise { + const taskId = activeUpTasks.get(workspaceId) + if (taskId) { + // Retain the mapping until the cancel lands, and let a failure reject. + await cli.run(["workspace", "task", "cancel", taskId]) + if (activeUpTasks.get(workspaceId) === taskId) { + activeUpTasks.delete(workspaceId) + } + } + const tunnelProc = tunnelProcesses.get(workspaceId) if (tunnelProc) { tunnelProcesses.delete(workspaceId) @@ -186,7 +225,22 @@ export function registerIpcHandlers(deps: IpcDependencies): { tunnelProc.kill("SIGTERM") await tunnelExit } - await Promise.all([cli.cancelFor(workspaceId), pty.cancelFor(workspaceId)]) + } + + /** + * Terminate every desktop-spawned process tied to a workspace and wait for + * them to actually exit. Called before stop/delete so destructive CLI runs + * don't race with in-flight children that are still appending to the + * workspace's log directory. + */ + async function quiesceWorkspace(workspaceId: string): Promise { + await serializePerWorkspace(workspaceId, async () => { + await cancelActiveUp(workspaceId) + await Promise.all([ + cli.cancelFor(workspaceId), + pty.cancelFor(workspaceId), + ]) + }) } /** @@ -583,7 +637,10 @@ export function registerIpcHandlers(deps: IpcDependencies): { async (_event, args: { name: string; value: string }) => { trackEvent("secret_set") try { - await cli.runRawStdin(["secret", "set", args.name, "--stdin"], args.value) + await cli.runRawStdin( + ["secret", "set", args.name, "--stdin"], + args.value, + ) return { ok: true } as const } catch (err) { const cliError = (err as { cliError?: CLIError }).cliError @@ -721,8 +778,7 @@ export function registerIpcHandlers(deps: IpcDependencies): { if (args.debug) cliArgs.push("--debug") if (args.workspaceFolder) cliArgs.push("--workspace-folder", args.workspaceFolder) - if (args.devcontainer) - cliArgs.push("--devcontainer", args.devcontainer) + if (args.devcontainer) cliArgs.push("--devcontainer", args.devcontainer) if (args.prebuildRepository) cliArgs.push("--prebuild-repo", args.prebuildRepository) if (args.platform) cliArgs.push("--platform", args.platform) @@ -737,45 +793,123 @@ export function registerIpcHandlers(deps: IpcDependencies): { (line) => logStore.appendLog(logPath, line), () => logStore.closeLog(logPath), ) - let signalledDone = false - - // Kill any existing tunnel process for this workspace before starting a new one - const existing = tunnelProcesses.get(wsId) - if (existing) { - existing.kill("SIGTERM") - tunnelProcesses.delete(wsId) - } - const child = await cli.runStreaming( - cliArgs, - (line) => { - if (signalledDone) return - const formatted = formatLogLine(line) - - if (line.includes('"outcome":"success"')) { - signalledDone = true - // Track this as a tunnel process (it stays alive for the tunnel) - tunnelProcesses.set(wsId, child) - void sink.done(formatted) - return + return serializePerWorkspace(wsId, async () => { + // Tear down any prior run for this workspace before starting a new + // one. + let taskId: string + try { + await cancelActiveUp(wsId) + // Submit: returns immediately with the background task's id. + const submitted = await cli.run<{ kind: string; id: string }>([ + ...cliArgs, + "--detach", + ]) + if (!submitted?.id) { + throw new Error("workspace up --detach returned no task id") } + taskId = submitted.id + } catch (error) { + const err = error as Error & { cliError?: CLIError } + void sink.done(formatLogLine(err.message, "ERROR"), { + level: "error", + cliError: err.cliError ?? { + code: "up_failed", + message: err.message, + }, + }) + return cmdId + } + activeUpTasks.set(wsId, taskId) - if (!sink.line(formatted)) return logStore.onDrain(logPath) - }, - (code, cliError) => { - if (tunnelProcesses.get(wsId) === child) { - tunnelProcesses.delete(wsId) + // A newer submission may already own the entry and must stay cancellable. + const releaseTask = () => { + if (activeUpTasks.get(wsId) === taskId) { + activeUpTasks.delete(wsId) } - if (signalledDone) return - void sink.done( - formatLogLine(`Exit code: ${code}`, code === 0 ? "INFO" : "ERROR"), - code === 0 ? undefined : { level: "error", cliError }, + } + + let signalledDone = false + let child: import("node:child_process").ChildProcess + try { + child = await cli.runStreaming( + ["workspace", "task", "logs", taskId, "--follow"], + (line, stream) => { + if (signalledDone) return + + // Structured NDJSON envelopes only ever appear on stdout; stderr + // carries freeform zap log lines. + const envelope = + stream === "stdout" ? parseCliEnvelope(line) : undefined + + if (envelope?.kind === "status") { + deps.getMainWindow()?.webContents.send("workspace-status", { + commandId: cmdId, + workspaceId: wsId, + phase: envelope.phase, + step: envelope.step, + started: envelope.started, + error: envelope.error, + }) + return + } + + const formatted = formatLogLine(line) + + if (envelope?.kind === "result") { + signalledDone = true + releaseTask() + void sink.done(formatted) + return + } + + if (envelope?.kind === "error") { + signalledDone = true + releaseTask() + void sink.done(formatted, { + level: "error", + cliError: { code: "up_failed", message: envelope.message }, + }) + return + } + + if (!sink.line(formatted)) return logStore.onDrain(logPath) + }, + (code, cliError) => { + // No releaseTask: the follower dying says nothing about the + // detached worker, and would orphan a still-running task. + if (tunnelProcesses.get(wsId) === child) { + tunnelProcesses.delete(wsId) + } + if (signalledDone) return + void sink.done( + formatLogLine( + `Exit code: ${code}`, + code === 0 ? "INFO" : "ERROR", + ), + code === 0 ? undefined : { level: "error", cliError }, + ) + }, + wsId, ) - }, - wsId, - ) + } catch (error) { + // The task is already submitted; keep it registered so a later + // cancel can still reach it, and close the sink so the UI isn't + // left waiting on a follower that never started. + const err = error as Error & { cliError?: CLIError } + void sink.done(formatLogLine(err.message, "ERROR"), { + level: "error", + cliError: err.cliError ?? { + code: "up_follow_failed", + message: err.message, + }, + }) + return cmdId + } + tunnelProcesses.set(wsId, child) - return cmdId + return cmdId + }) }, ) diff --git a/desktop/src/renderer/src/lib/ipc/events.ts b/desktop/src/renderer/src/lib/ipc/events.ts index 2cb97192a..707afe7aa 100644 --- a/desktop/src/renderer/src/lib/ipc/events.ts +++ b/desktop/src/renderer/src/lib/ipc/events.ts @@ -4,6 +4,7 @@ import type { Machine, Provider, Workspace, + WorkspaceStatus, } from "$lib/types/index.js" import { listen } from "./bridge.js" import type { UnlistenFn } from "./types.js" @@ -48,6 +49,7 @@ export const EVENT_NAMES = { MACHINES_CHANGED: "machines-changed", CONTEXTS_CHANGED: "contexts-changed", COMMAND_PROGRESS: "command-progress", + WORKSPACE_STATUS: "workspace-status", UPDATE_STATUS: "update-status", } as const @@ -105,6 +107,14 @@ export function onCommandProgress( }) } +export function onWorkspaceStatus( + callback: (status: WorkspaceStatus) => void, +): Promise { + return listen(EVENT_NAMES.WORKSPACE_STATUS, (event) => { + callback(event.payload) + }) +} + export function onUpdateStatus( callback: (status: UpdateStatus) => void, ): Promise { diff --git a/desktop/src/renderer/src/lib/types/index.ts b/desktop/src/renderer/src/lib/types/index.ts index 4acbd170c..9808c1046 100644 --- a/desktop/src/renderer/src/lib/types/index.ts +++ b/desktop/src/renderer/src/lib/types/index.ts @@ -148,6 +148,16 @@ export interface CommandProgress { cliError?: import("../../../../shared/cli-error.js").CLIError } +/** A workspace phase transition, pushed as it happens. */ +export interface WorkspaceStatus { + commandId: string + workspaceId: string + phase: string + step?: string + started: boolean + error?: string +} + export interface AuditEntry { id: number timestamp: string diff --git a/desktop/src/shared/cli-error.ts b/desktop/src/shared/cli-error.ts index fa67da46a..f135cabad 100644 --- a/desktop/src/shared/cli-error.ts +++ b/desktop/src/shared/cli-error.ts @@ -10,3 +10,56 @@ export interface CliLogLine { cliError?: CLIError [key: string]: unknown } + +export type CliEnvelopeKind = "status" | "result" | "error" + +export interface CliStatusEnvelope { + kind: "status" + phase: string + step?: string + started: boolean + error?: string +} + +export interface CliResultEnvelope { + kind: "result" + outcome: "success" + containerId: string + remoteUser: string + remoteWorkspaceFolder: string + url?: string + warnings?: string[] + recovery?: boolean +} + +export interface CliErrorEnvelope { + kind: "error" + outcome: "error" + message: string +} + +export type CliEnvelope = + | CliStatusEnvelope + | CliResultEnvelope + | CliErrorEnvelope + +/** Returns undefined when line isn't a recognized envelope. */ +export function parseCliEnvelope(line: string): CliEnvelope | undefined { + const trimmed = line.trim() + if (!trimmed.startsWith("{")) return undefined + try { + const obj = JSON.parse(trimmed) as unknown + if ( + obj && + typeof obj === "object" && + "kind" in obj && + (obj as { kind: unknown }).kind && + ["status", "result", "error"].includes((obj as { kind: string }).kind) + ) { + return obj as CliEnvelope + } + } catch { + // not JSON — fall through + } + return undefined +} diff --git a/pkg/agent/tunnel/tunnel.pb.go b/pkg/agent/tunnel/tunnel.pb.go index 55197c060..3e66fda3a 100644 --- a/pkg/agent/tunnel/tunnel.pb.go +++ b/pkg/agent/tunnel/tunnel.pb.go @@ -2,8 +2,8 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.4 -// protoc v7.34.1 +// protoc-gen-go v1.36.11 +// protoc v7.35.1 // source: tunnel.proto package tunnel @@ -526,6 +526,74 @@ func (x *LogMessage) GetMessage() string { return "" } +type StatusUpdate struct { + state protoimpl.MessageState `protogen:"open.v1"` + Phase string `protobuf:"bytes,1,opt,name=phase,proto3" json:"phase,omitempty"` + Step string `protobuf:"bytes,2,opt,name=step,proto3" json:"step,omitempty"` + Started bool `protobuf:"varint,3,opt,name=started,proto3" json:"started,omitempty"` + Error string `protobuf:"bytes,4,opt,name=error,proto3" json:"error,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StatusUpdate) Reset() { + *x = StatusUpdate{} + mi := &file_tunnel_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StatusUpdate) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StatusUpdate) ProtoMessage() {} + +func (x *StatusUpdate) ProtoReflect() protoreflect.Message { + mi := &file_tunnel_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StatusUpdate.ProtoReflect.Descriptor instead. +func (*StatusUpdate) Descriptor() ([]byte, []int) { + return file_tunnel_proto_rawDescGZIP(), []int{10} +} + +func (x *StatusUpdate) GetPhase() string { + if x != nil { + return x.Phase + } + return "" +} + +func (x *StatusUpdate) GetStep() string { + if x != nil { + return x.Step + } + return "" +} + +func (x *StatusUpdate) GetStarted() bool { + if x != nil { + return x.Started + } + return false +} + +func (x *StatusUpdate) GetError() string { + if x != nil { + return x.Error + } + return "" +} + type Empty struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -534,7 +602,7 @@ type Empty struct { func (x *Empty) Reset() { *x = Empty{} - mi := &file_tunnel_proto_msgTypes[10] + mi := &file_tunnel_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -546,7 +614,7 @@ func (x *Empty) String() string { func (*Empty) ProtoMessage() {} func (x *Empty) ProtoReflect() protoreflect.Message { - mi := &file_tunnel_proto_msgTypes[10] + mi := &file_tunnel_proto_msgTypes[11] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -559,105 +627,67 @@ func (x *Empty) ProtoReflect() protoreflect.Message { // Deprecated: Use Empty.ProtoReflect.Descriptor instead. func (*Empty) Descriptor() ([]byte, []int) { - return file_tunnel_proto_rawDescGZIP(), []int{10} + return file_tunnel_proto_rawDescGZIP(), []int{11} } var File_tunnel_proto protoreflect.FileDescriptor -var file_tunnel_proto_rawDesc = string([]byte{ - 0x0a, 0x0c, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x06, - 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x22, 0x2a, 0x0a, 0x12, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, - 0x4d, 0x6f, 0x75, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x14, 0x0a, 0x05, - 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6d, 0x6f, 0x75, - 0x6e, 0x74, 0x22, 0x2c, 0x0a, 0x16, 0x53, 0x74, 0x6f, 0x70, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, - 0x64, 0x50, 0x6f, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, - 0x70, 0x6f, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x70, 0x6f, 0x72, 0x74, - 0x22, 0x19, 0x0a, 0x17, 0x53, 0x74, 0x6f, 0x70, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x50, - 0x6f, 0x72, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x28, 0x0a, 0x12, 0x46, - 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x50, 0x6f, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x04, 0x70, 0x6f, 0x72, 0x74, 0x22, 0x15, 0x0a, 0x13, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, - 0x50, 0x6f, 0x72, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x23, 0x0a, 0x07, - 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, - 0x67, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, - 0x65, 0x22, 0x48, 0x0a, 0x06, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x6e, - 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, - 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, - 0x76, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x18, 0x03, - 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x6d, 0x6f, 0x75, 0x6e, 0x74, 0x22, 0x3b, 0x0a, 0x0f, 0x53, - 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x28, - 0x0a, 0x07, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, - 0x0e, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x53, 0x65, 0x63, 0x72, 0x65, 0x74, 0x52, - 0x07, 0x73, 0x65, 0x63, 0x72, 0x65, 0x74, 0x73, 0x22, 0x21, 0x0a, 0x05, 0x43, 0x68, 0x75, 0x6e, - 0x6b, 0x12, 0x18, 0x0a, 0x07, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x0c, 0x52, 0x07, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x22, 0x54, 0x0a, 0x0a, 0x4c, - 0x6f, 0x67, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x2c, 0x0a, 0x08, 0x6c, 0x6f, 0x67, - 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x10, 0x2e, 0x74, 0x75, - 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4c, 0x6f, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x52, 0x08, 0x6c, - 0x6f, 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, - 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, - 0x65, 0x22, 0x07, 0x0a, 0x05, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x2a, 0x41, 0x0a, 0x08, 0x4c, 0x6f, - 0x67, 0x4c, 0x65, 0x76, 0x65, 0x6c, 0x12, 0x09, 0x0a, 0x05, 0x44, 0x45, 0x42, 0x55, 0x47, 0x10, - 0x00, 0x12, 0x08, 0x0a, 0x04, 0x49, 0x4e, 0x46, 0x4f, 0x10, 0x01, 0x12, 0x08, 0x0a, 0x04, 0x44, - 0x4f, 0x4e, 0x45, 0x10, 0x02, 0x12, 0x0b, 0x0a, 0x07, 0x57, 0x41, 0x52, 0x4e, 0x49, 0x4e, 0x47, - 0x10, 0x03, 0x12, 0x09, 0x0a, 0x05, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, 0x04, 0x32, 0xc1, 0x06, - 0x0a, 0x06, 0x54, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x12, 0x26, 0x0a, 0x04, 0x50, 0x69, 0x6e, 0x67, - 0x12, 0x0d, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, - 0x0d, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, - 0x12, 0x2a, 0x0a, 0x03, 0x4c, 0x6f, 0x67, 0x12, 0x12, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, - 0x2e, 0x4c, 0x6f, 0x67, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x0d, 0x2e, 0x74, 0x75, - 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x2e, 0x0a, 0x0a, - 0x53, 0x65, 0x6e, 0x64, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x0f, 0x2e, 0x74, 0x75, 0x6e, - 0x6e, 0x65, 0x6c, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x0d, 0x2e, 0x74, 0x75, - 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x00, 0x12, 0x37, 0x0a, 0x11, - 0x44, 0x6f, 0x63, 0x6b, 0x65, 0x72, 0x43, 0x72, 0x65, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x61, 0x6c, - 0x73, 0x12, 0x0f, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, - 0x67, 0x65, 0x1a, 0x0f, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4d, 0x65, 0x73, 0x73, - 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x34, 0x0a, 0x0e, 0x47, 0x69, 0x74, 0x43, 0x72, 0x65, 0x64, - 0x65, 0x6e, 0x74, 0x69, 0x61, 0x6c, 0x73, 0x12, 0x0f, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, - 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x0f, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, - 0x6c, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x35, 0x0a, 0x0f, 0x47, - 0x69, 0x74, 0x53, 0x53, 0x48, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x12, 0x0f, - 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, - 0x0f, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, - 0x22, 0x00, 0x12, 0x2b, 0x0a, 0x07, 0x47, 0x69, 0x74, 0x55, 0x73, 0x65, 0x72, 0x12, 0x0d, 0x2e, - 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x1a, 0x0f, 0x2e, 0x74, - 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, - 0x31, 0x0a, 0x0b, 0x44, 0x65, 0x76, 0x73, 0x79, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x0f, - 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, - 0x0f, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, - 0x22, 0x00, 0x12, 0x33, 0x0a, 0x0d, 0x47, 0x50, 0x47, 0x50, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, - 0x65, 0x79, 0x73, 0x12, 0x0f, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4d, 0x65, 0x73, - 0x73, 0x61, 0x67, 0x65, 0x1a, 0x0f, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4d, 0x65, - 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x30, 0x0a, 0x0a, 0x4b, 0x75, 0x62, 0x65, 0x43, - 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x0f, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x4d, - 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x1a, 0x0f, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, - 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x00, 0x12, 0x33, 0x0a, 0x07, 0x53, 0x65, 0x63, - 0x72, 0x65, 0x74, 0x73, 0x12, 0x0d, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x45, 0x6d, - 0x70, 0x74, 0x79, 0x1a, 0x17, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x53, 0x65, 0x63, - 0x72, 0x65, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x48, - 0x0a, 0x0b, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x1a, 0x2e, - 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x50, 0x6f, - 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1b, 0x2e, 0x74, 0x75, 0x6e, 0x6e, - 0x65, 0x6c, 0x2e, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x50, 0x6f, 0x72, 0x74, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x54, 0x0a, 0x0f, 0x53, 0x74, 0x6f, 0x70, - 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x1e, 0x2e, 0x74, 0x75, - 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x53, 0x74, 0x6f, 0x70, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, - 0x50, 0x6f, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x74, 0x75, - 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x53, 0x74, 0x6f, 0x70, 0x46, 0x6f, 0x72, 0x77, 0x61, 0x72, 0x64, - 0x50, 0x6f, 0x72, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x33, - 0x0a, 0x0f, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, - 0x65, 0x12, 0x0d, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, - 0x1a, 0x0d, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x43, 0x68, 0x75, 0x6e, 0x6b, 0x22, - 0x00, 0x30, 0x01, 0x12, 0x3c, 0x0a, 0x0b, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x4d, 0x6f, 0x75, - 0x6e, 0x74, 0x12, 0x1a, 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x53, 0x74, 0x72, 0x65, - 0x61, 0x6d, 0x4d, 0x6f, 0x75, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x0d, - 0x2e, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x2e, 0x43, 0x68, 0x75, 0x6e, 0x6b, 0x22, 0x00, 0x30, - 0x01, 0x42, 0x2d, 0x5a, 0x2b, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, - 0x64, 0x65, 0x76, 0x73, 0x79, 0x2d, 0x6f, 0x72, 0x67, 0x2f, 0x64, 0x65, 0x76, 0x73, 0x79, 0x2f, - 0x70, 0x6b, 0x67, 0x2f, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2f, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, - 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, -}) +const file_tunnel_proto_rawDesc = "" + + "\n" + + "\ftunnel.proto\x12\x06tunnel\"*\n" + + "\x12StreamMountRequest\x12\x14\n" + + "\x05mount\x18\x01 \x01(\tR\x05mount\",\n" + + "\x16StopForwardPortRequest\x12\x12\n" + + "\x04port\x18\x01 \x01(\tR\x04port\"\x19\n" + + "\x17StopForwardPortResponse\"(\n" + + "\x12ForwardPortRequest\x12\x12\n" + + "\x04port\x18\x01 \x01(\tR\x04port\"\x15\n" + + "\x13ForwardPortResponse\"#\n" + + "\aMessage\x12\x18\n" + + "\amessage\x18\x01 \x01(\tR\amessage\"H\n" + + "\x06Secret\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value\x12\x14\n" + + "\x05mount\x18\x03 \x01(\bR\x05mount\";\n" + + "\x0fSecretsResponse\x12(\n" + + "\asecrets\x18\x01 \x03(\v2\x0e.tunnel.SecretR\asecrets\"!\n" + + "\x05Chunk\x12\x18\n" + + "\aContent\x18\x01 \x01(\fR\aContent\"T\n" + + "\n" + + "LogMessage\x12,\n" + + "\blogLevel\x18\x01 \x01(\x0e2\x10.tunnel.LogLevelR\blogLevel\x12\x18\n" + + "\amessage\x18\x02 \x01(\tR\amessage\"h\n" + + "\fStatusUpdate\x12\x14\n" + + "\x05phase\x18\x01 \x01(\tR\x05phase\x12\x12\n" + + "\x04step\x18\x02 \x01(\tR\x04step\x12\x18\n" + + "\astarted\x18\x03 \x01(\bR\astarted\x12\x14\n" + + "\x05error\x18\x04 \x01(\tR\x05error\"\a\n" + + "\x05Empty*A\n" + + "\bLogLevel\x12\t\n" + + "\x05DEBUG\x10\x00\x12\b\n" + + "\x04INFO\x10\x01\x12\b\n" + + "\x04DONE\x10\x02\x12\v\n" + + "\aWARNING\x10\x03\x12\t\n" + + "\x05ERROR\x10\x042\xf2\x06\n" + + "\x06Tunnel\x12&\n" + + "\x04Ping\x12\r.tunnel.Empty\x1a\r.tunnel.Empty\"\x00\x12*\n" + + "\x03Log\x12\x12.tunnel.LogMessage\x1a\r.tunnel.Empty\"\x00\x12/\n" + + "\x06Status\x12\x14.tunnel.StatusUpdate\x1a\r.tunnel.Empty\"\x00\x12.\n" + + "\n" + + "SendResult\x12\x0f.tunnel.Message\x1a\r.tunnel.Empty\"\x00\x127\n" + + "\x11DockerCredentials\x12\x0f.tunnel.Message\x1a\x0f.tunnel.Message\"\x00\x124\n" + + "\x0eGitCredentials\x12\x0f.tunnel.Message\x1a\x0f.tunnel.Message\"\x00\x125\n" + + "\x0fGitSSHSignature\x12\x0f.tunnel.Message\x1a\x0f.tunnel.Message\"\x00\x12+\n" + + "\aGitUser\x12\r.tunnel.Empty\x1a\x0f.tunnel.Message\"\x00\x121\n" + + "\vDevsyConfig\x12\x0f.tunnel.Message\x1a\x0f.tunnel.Message\"\x00\x123\n" + + "\rGPGPublicKeys\x12\x0f.tunnel.Message\x1a\x0f.tunnel.Message\"\x00\x120\n" + + "\n" + + "KubeConfig\x12\x0f.tunnel.Message\x1a\x0f.tunnel.Message\"\x00\x123\n" + + "\aSecrets\x12\r.tunnel.Empty\x1a\x17.tunnel.SecretsResponse\"\x00\x12H\n" + + "\vForwardPort\x12\x1a.tunnel.ForwardPortRequest\x1a\x1b.tunnel.ForwardPortResponse\"\x00\x12T\n" + + "\x0fStopForwardPort\x12\x1e.tunnel.StopForwardPortRequest\x1a\x1f.tunnel.StopForwardPortResponse\"\x00\x123\n" + + "\x0fStreamWorkspace\x12\r.tunnel.Empty\x1a\r.tunnel.Chunk\"\x000\x01\x12<\n" + + "\vStreamMount\x12\x1a.tunnel.StreamMountRequest\x1a\r.tunnel.Chunk\"\x000\x01B-Z+github.com/devsy-org/devsy/pkg/agent/tunnelb\x06proto3" var ( file_tunnel_proto_rawDescOnce sync.Once @@ -672,7 +702,7 @@ func file_tunnel_proto_rawDescGZIP() []byte { } var file_tunnel_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_tunnel_proto_msgTypes = make([]protoimpl.MessageInfo, 11) +var file_tunnel_proto_msgTypes = make([]protoimpl.MessageInfo, 12) var file_tunnel_proto_goTypes = []any{ (LogLevel)(0), // 0: tunnel.LogLevel (*StreamMountRequest)(nil), // 1: tunnel.StreamMountRequest @@ -685,43 +715,46 @@ var file_tunnel_proto_goTypes = []any{ (*SecretsResponse)(nil), // 8: tunnel.SecretsResponse (*Chunk)(nil), // 9: tunnel.Chunk (*LogMessage)(nil), // 10: tunnel.LogMessage - (*Empty)(nil), // 11: tunnel.Empty + (*StatusUpdate)(nil), // 11: tunnel.StatusUpdate + (*Empty)(nil), // 12: tunnel.Empty } var file_tunnel_proto_depIdxs = []int32{ 7, // 0: tunnel.SecretsResponse.secrets:type_name -> tunnel.Secret 0, // 1: tunnel.LogMessage.logLevel:type_name -> tunnel.LogLevel - 11, // 2: tunnel.Tunnel.Ping:input_type -> tunnel.Empty + 12, // 2: tunnel.Tunnel.Ping:input_type -> tunnel.Empty 10, // 3: tunnel.Tunnel.Log:input_type -> tunnel.LogMessage - 6, // 4: tunnel.Tunnel.SendResult:input_type -> tunnel.Message - 6, // 5: tunnel.Tunnel.DockerCredentials:input_type -> tunnel.Message - 6, // 6: tunnel.Tunnel.GitCredentials:input_type -> tunnel.Message - 6, // 7: tunnel.Tunnel.GitSSHSignature:input_type -> tunnel.Message - 11, // 8: tunnel.Tunnel.GitUser:input_type -> tunnel.Empty - 6, // 9: tunnel.Tunnel.DevsyConfig:input_type -> tunnel.Message - 6, // 10: tunnel.Tunnel.GPGPublicKeys:input_type -> tunnel.Message - 6, // 11: tunnel.Tunnel.KubeConfig:input_type -> tunnel.Message - 11, // 12: tunnel.Tunnel.Secrets:input_type -> tunnel.Empty - 4, // 13: tunnel.Tunnel.ForwardPort:input_type -> tunnel.ForwardPortRequest - 2, // 14: tunnel.Tunnel.StopForwardPort:input_type -> tunnel.StopForwardPortRequest - 11, // 15: tunnel.Tunnel.StreamWorkspace:input_type -> tunnel.Empty - 1, // 16: tunnel.Tunnel.StreamMount:input_type -> tunnel.StreamMountRequest - 11, // 17: tunnel.Tunnel.Ping:output_type -> tunnel.Empty - 11, // 18: tunnel.Tunnel.Log:output_type -> tunnel.Empty - 11, // 19: tunnel.Tunnel.SendResult:output_type -> tunnel.Empty - 6, // 20: tunnel.Tunnel.DockerCredentials:output_type -> tunnel.Message - 6, // 21: tunnel.Tunnel.GitCredentials:output_type -> tunnel.Message - 6, // 22: tunnel.Tunnel.GitSSHSignature:output_type -> tunnel.Message - 6, // 23: tunnel.Tunnel.GitUser:output_type -> tunnel.Message - 6, // 24: tunnel.Tunnel.DevsyConfig:output_type -> tunnel.Message - 6, // 25: tunnel.Tunnel.GPGPublicKeys:output_type -> tunnel.Message - 6, // 26: tunnel.Tunnel.KubeConfig:output_type -> tunnel.Message - 8, // 27: tunnel.Tunnel.Secrets:output_type -> tunnel.SecretsResponse - 5, // 28: tunnel.Tunnel.ForwardPort:output_type -> tunnel.ForwardPortResponse - 3, // 29: tunnel.Tunnel.StopForwardPort:output_type -> tunnel.StopForwardPortResponse - 9, // 30: tunnel.Tunnel.StreamWorkspace:output_type -> tunnel.Chunk - 9, // 31: tunnel.Tunnel.StreamMount:output_type -> tunnel.Chunk - 17, // [17:32] is the sub-list for method output_type - 2, // [2:17] is the sub-list for method input_type + 11, // 4: tunnel.Tunnel.Status:input_type -> tunnel.StatusUpdate + 6, // 5: tunnel.Tunnel.SendResult:input_type -> tunnel.Message + 6, // 6: tunnel.Tunnel.DockerCredentials:input_type -> tunnel.Message + 6, // 7: tunnel.Tunnel.GitCredentials:input_type -> tunnel.Message + 6, // 8: tunnel.Tunnel.GitSSHSignature:input_type -> tunnel.Message + 12, // 9: tunnel.Tunnel.GitUser:input_type -> tunnel.Empty + 6, // 10: tunnel.Tunnel.DevsyConfig:input_type -> tunnel.Message + 6, // 11: tunnel.Tunnel.GPGPublicKeys:input_type -> tunnel.Message + 6, // 12: tunnel.Tunnel.KubeConfig:input_type -> tunnel.Message + 12, // 13: tunnel.Tunnel.Secrets:input_type -> tunnel.Empty + 4, // 14: tunnel.Tunnel.ForwardPort:input_type -> tunnel.ForwardPortRequest + 2, // 15: tunnel.Tunnel.StopForwardPort:input_type -> tunnel.StopForwardPortRequest + 12, // 16: tunnel.Tunnel.StreamWorkspace:input_type -> tunnel.Empty + 1, // 17: tunnel.Tunnel.StreamMount:input_type -> tunnel.StreamMountRequest + 12, // 18: tunnel.Tunnel.Ping:output_type -> tunnel.Empty + 12, // 19: tunnel.Tunnel.Log:output_type -> tunnel.Empty + 12, // 20: tunnel.Tunnel.Status:output_type -> tunnel.Empty + 12, // 21: tunnel.Tunnel.SendResult:output_type -> tunnel.Empty + 6, // 22: tunnel.Tunnel.DockerCredentials:output_type -> tunnel.Message + 6, // 23: tunnel.Tunnel.GitCredentials:output_type -> tunnel.Message + 6, // 24: tunnel.Tunnel.GitSSHSignature:output_type -> tunnel.Message + 6, // 25: tunnel.Tunnel.GitUser:output_type -> tunnel.Message + 6, // 26: tunnel.Tunnel.DevsyConfig:output_type -> tunnel.Message + 6, // 27: tunnel.Tunnel.GPGPublicKeys:output_type -> tunnel.Message + 6, // 28: tunnel.Tunnel.KubeConfig:output_type -> tunnel.Message + 8, // 29: tunnel.Tunnel.Secrets:output_type -> tunnel.SecretsResponse + 5, // 30: tunnel.Tunnel.ForwardPort:output_type -> tunnel.ForwardPortResponse + 3, // 31: tunnel.Tunnel.StopForwardPort:output_type -> tunnel.StopForwardPortResponse + 9, // 32: tunnel.Tunnel.StreamWorkspace:output_type -> tunnel.Chunk + 9, // 33: tunnel.Tunnel.StreamMount:output_type -> tunnel.Chunk + 18, // [18:34] is the sub-list for method output_type + 2, // [2:18] is the sub-list for method input_type 2, // [2:2] is the sub-list for extension type_name 2, // [2:2] is the sub-list for extension extendee 0, // [0:2] is the sub-list for field type_name @@ -738,7 +771,7 @@ func file_tunnel_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_tunnel_proto_rawDesc), len(file_tunnel_proto_rawDesc)), NumEnums: 1, - NumMessages: 11, + NumMessages: 12, NumExtensions: 0, NumServices: 1, }, diff --git a/pkg/agent/tunnel/tunnel.proto b/pkg/agent/tunnel/tunnel.proto index c8442294b..4493ee924 100644 --- a/pkg/agent/tunnel/tunnel.proto +++ b/pkg/agent/tunnel/tunnel.proto @@ -8,6 +8,7 @@ package tunnel; service Tunnel { rpc Ping(Empty) returns (Empty) {} rpc Log(LogMessage) returns (Empty) {} + rpc Status(StatusUpdate) returns (Empty) {} rpc SendResult(Message) returns (Empty) {} rpc DockerCredentials(Message) returns (Message) {} @@ -77,6 +78,13 @@ message LogMessage { string message = 2; } +message StatusUpdate { + string phase = 1; + string step = 2; + bool started = 3; + string error = 4; +} + message Empty { } diff --git a/pkg/agent/tunnel/tunnel_grpc.pb.go b/pkg/agent/tunnel/tunnel_grpc.pb.go index ab5aac9e2..75f41efe5 100644 --- a/pkg/agent/tunnel/tunnel_grpc.pb.go +++ b/pkg/agent/tunnel/tunnel_grpc.pb.go @@ -2,8 +2,8 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: -// - protoc-gen-go-grpc v1.5.1 -// - protoc v7.34.1 +// - protoc-gen-go-grpc v1.6.2 +// - protoc v7.35.1 // source: tunnel.proto package tunnel @@ -23,6 +23,7 @@ const _ = grpc.SupportPackageIsVersion9 const ( Tunnel_Ping_FullMethodName = "/tunnel.Tunnel/Ping" Tunnel_Log_FullMethodName = "/tunnel.Tunnel/Log" + Tunnel_Status_FullMethodName = "/tunnel.Tunnel/Status" Tunnel_SendResult_FullMethodName = "/tunnel.Tunnel/SendResult" Tunnel_DockerCredentials_FullMethodName = "/tunnel.Tunnel/DockerCredentials" Tunnel_GitCredentials_FullMethodName = "/tunnel.Tunnel/GitCredentials" @@ -44,6 +45,7 @@ const ( type TunnelClient interface { Ping(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*Empty, error) Log(ctx context.Context, in *LogMessage, opts ...grpc.CallOption) (*Empty, error) + Status(ctx context.Context, in *StatusUpdate, opts ...grpc.CallOption) (*Empty, error) SendResult(ctx context.Context, in *Message, opts ...grpc.CallOption) (*Empty, error) DockerCredentials(ctx context.Context, in *Message, opts ...grpc.CallOption) (*Message, error) GitCredentials(ctx context.Context, in *Message, opts ...grpc.CallOption) (*Message, error) @@ -87,6 +89,16 @@ func (c *tunnelClient) Log(ctx context.Context, in *LogMessage, opts ...grpc.Cal return out, nil } +func (c *tunnelClient) Status(ctx context.Context, in *StatusUpdate, opts ...grpc.CallOption) (*Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Empty) + err := c.cc.Invoke(ctx, Tunnel_Status_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *tunnelClient) SendResult(ctx context.Context, in *Message, opts ...grpc.CallOption) (*Empty, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(Empty) @@ -241,6 +253,7 @@ type Tunnel_StreamMountClient = grpc.ServerStreamingClient[Chunk] type TunnelServer interface { Ping(context.Context, *Empty) (*Empty, error) Log(context.Context, *LogMessage) (*Empty, error) + Status(context.Context, *StatusUpdate) (*Empty, error) SendResult(context.Context, *Message) (*Empty, error) DockerCredentials(context.Context, *Message) (*Message, error) GitCredentials(context.Context, *Message) (*Message, error) @@ -265,49 +278,52 @@ type TunnelServer interface { type UnimplementedTunnelServer struct{} func (UnimplementedTunnelServer) Ping(context.Context, *Empty) (*Empty, error) { - return nil, status.Errorf(codes.Unimplemented, "method Ping not implemented") + return nil, status.Error(codes.Unimplemented, "method Ping not implemented") } func (UnimplementedTunnelServer) Log(context.Context, *LogMessage) (*Empty, error) { - return nil, status.Errorf(codes.Unimplemented, "method Log not implemented") + return nil, status.Error(codes.Unimplemented, "method Log not implemented") +} +func (UnimplementedTunnelServer) Status(context.Context, *StatusUpdate) (*Empty, error) { + return nil, status.Error(codes.Unimplemented, "method Status not implemented") } func (UnimplementedTunnelServer) SendResult(context.Context, *Message) (*Empty, error) { - return nil, status.Errorf(codes.Unimplemented, "method SendResult not implemented") + return nil, status.Error(codes.Unimplemented, "method SendResult not implemented") } func (UnimplementedTunnelServer) DockerCredentials(context.Context, *Message) (*Message, error) { - return nil, status.Errorf(codes.Unimplemented, "method DockerCredentials not implemented") + return nil, status.Error(codes.Unimplemented, "method DockerCredentials not implemented") } func (UnimplementedTunnelServer) GitCredentials(context.Context, *Message) (*Message, error) { - return nil, status.Errorf(codes.Unimplemented, "method GitCredentials not implemented") + return nil, status.Error(codes.Unimplemented, "method GitCredentials not implemented") } func (UnimplementedTunnelServer) GitSSHSignature(context.Context, *Message) (*Message, error) { - return nil, status.Errorf(codes.Unimplemented, "method GitSSHSignature not implemented") + return nil, status.Error(codes.Unimplemented, "method GitSSHSignature not implemented") } func (UnimplementedTunnelServer) GitUser(context.Context, *Empty) (*Message, error) { - return nil, status.Errorf(codes.Unimplemented, "method GitUser not implemented") + return nil, status.Error(codes.Unimplemented, "method GitUser not implemented") } func (UnimplementedTunnelServer) DevsyConfig(context.Context, *Message) (*Message, error) { - return nil, status.Errorf(codes.Unimplemented, "method DevsyConfig not implemented") + return nil, status.Error(codes.Unimplemented, "method DevsyConfig not implemented") } func (UnimplementedTunnelServer) GPGPublicKeys(context.Context, *Message) (*Message, error) { - return nil, status.Errorf(codes.Unimplemented, "method GPGPublicKeys not implemented") + return nil, status.Error(codes.Unimplemented, "method GPGPublicKeys not implemented") } func (UnimplementedTunnelServer) KubeConfig(context.Context, *Message) (*Message, error) { - return nil, status.Errorf(codes.Unimplemented, "method KubeConfig not implemented") + return nil, status.Error(codes.Unimplemented, "method KubeConfig not implemented") } func (UnimplementedTunnelServer) Secrets(context.Context, *Empty) (*SecretsResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method Secrets not implemented") + return nil, status.Error(codes.Unimplemented, "method Secrets not implemented") } func (UnimplementedTunnelServer) ForwardPort(context.Context, *ForwardPortRequest) (*ForwardPortResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method ForwardPort not implemented") + return nil, status.Error(codes.Unimplemented, "method ForwardPort not implemented") } func (UnimplementedTunnelServer) StopForwardPort(context.Context, *StopForwardPortRequest) (*StopForwardPortResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method StopForwardPort not implemented") + return nil, status.Error(codes.Unimplemented, "method StopForwardPort not implemented") } func (UnimplementedTunnelServer) StreamWorkspace(*Empty, grpc.ServerStreamingServer[Chunk]) error { - return status.Errorf(codes.Unimplemented, "method StreamWorkspace not implemented") + return status.Error(codes.Unimplemented, "method StreamWorkspace not implemented") } func (UnimplementedTunnelServer) StreamMount(*StreamMountRequest, grpc.ServerStreamingServer[Chunk]) error { - return status.Errorf(codes.Unimplemented, "method StreamMount not implemented") + return status.Error(codes.Unimplemented, "method StreamMount not implemented") } func (UnimplementedTunnelServer) mustEmbedUnimplementedTunnelServer() {} func (UnimplementedTunnelServer) testEmbeddedByValue() {} @@ -320,7 +336,7 @@ type UnsafeTunnelServer interface { } func RegisterTunnelServer(s grpc.ServiceRegistrar, srv TunnelServer) { - // If the following call pancis, it indicates UnimplementedTunnelServer was + // If the following call panics, it indicates UnimplementedTunnelServer was // embedded by pointer and is nil. This will cause panics if an // unimplemented method is ever invoked, so we test this at initialization // time to prevent it from happening at runtime later due to I/O. @@ -366,6 +382,24 @@ func _Tunnel_Log_Handler(srv interface{}, ctx context.Context, dec func(interfac return interceptor(ctx, in, info, handler) } +func _Tunnel_Status_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(StatusUpdate) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(TunnelServer).Status(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Tunnel_Status_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(TunnelServer).Status(ctx, req.(*StatusUpdate)) + } + return interceptor(ctx, in, info, handler) +} + func _Tunnel_SendResult_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(Message) if err := dec(in); err != nil { @@ -601,6 +635,10 @@ var Tunnel_ServiceDesc = grpc.ServiceDesc{ MethodName: "Log", Handler: _Tunnel_Log_Handler, }, + { + MethodName: "Status", + Handler: _Tunnel_Status_Handler, + }, { MethodName: "SendResult", Handler: _Tunnel_SendResult_Handler, diff --git a/pkg/agent/tunnelserver/options.go b/pkg/agent/tunnelserver/options.go index e3ce13527..695ef3036 100644 --- a/pkg/agent/tunnelserver/options.go +++ b/pkg/agent/tunnelserver/options.go @@ -8,6 +8,7 @@ import ( "github.com/devsy-org/devsy/pkg/devcontainer/config" "github.com/devsy-org/devsy/pkg/netstat" provider2 "github.com/devsy-org/devsy/pkg/provider" + "github.com/devsy-org/devsy/pkg/status" ) type Option func(*tunnelServer) *tunnelServer @@ -75,6 +76,17 @@ func WithGitToken(token *provider2.GitToken) Option { } } +// WithStatusReporter forwards inbound StatusUpdate RPCs to reporter. A nil +// reporter is ignored, since it would replace the Nop default and panic. +func WithStatusReporter(reporter status.Reporter) Option { + return func(s *tunnelServer) *tunnelServer { + if reporter != nil { + s.statusReporter = reporter + } + return s + } +} + func toSecrets(entries []string, mount bool) []*tunnel.Secret { secrets := make([]*tunnel.Secret, 0, len(entries)) for _, entry := range entries { diff --git a/pkg/agent/tunnelserver/status_sender.go b/pkg/agent/tunnelserver/status_sender.go new file mode 100644 index 000000000..0ec524b6a --- /dev/null +++ b/pkg/agent/tunnelserver/status_sender.go @@ -0,0 +1,53 @@ +package tunnelserver + +import ( + "context" + "time" + + "github.com/devsy-org/devsy/pkg/agent/tunnel" + "github.com/devsy-org/devsy/pkg/status" +) + +// NewTunnelStatusReporter returns a status.Reporter that forwards each event +// to client over the Status RPC, mirroring NewTunnelLogger's buffered-worker +// pattern so a slow/unavailable peer cannot stall the up pipeline. +func NewTunnelStatusReporter(ctx context.Context, client tunnel.TunnelClient) status.Reporter { + r := &tunnelStatusReporter{ + ctx: ctx, + client: client, + events: make(chan *tunnel.StatusUpdate, 1000), + } + go r.worker() + return r +} + +type tunnelStatusReporter struct { + ctx context.Context + client tunnel.TunnelClient + events chan *tunnel.StatusUpdate +} + +func (r *tunnelStatusReporter) Report(e status.Event) { + select { + case r.events <- &tunnel.StatusUpdate{ + Phase: string(e.Phase), + Step: e.Step, + Started: e.Started, + Error: e.Err, + }: + case <-r.ctx.Done(): + } +} + +func (r *tunnelStatusReporter) worker() { + for { + select { + case update := <-r.events: + ctx, cancel := context.WithTimeout(r.ctx, 5*time.Second) + _, _ = r.client.Status(ctx, update) + cancel() + case <-r.ctx.Done(): + return + } + } +} diff --git a/pkg/agent/tunnelserver/tunnelserver.go b/pkg/agent/tunnelserver/tunnelserver.go index ab0ef9ab1..ccc898e2a 100644 --- a/pkg/agent/tunnelserver/tunnelserver.go +++ b/pkg/agent/tunnelserver/tunnelserver.go @@ -26,6 +26,7 @@ import ( "github.com/devsy-org/devsy/pkg/netstat" "github.com/devsy-org/devsy/pkg/platform" provider2 "github.com/devsy-org/devsy/pkg/provider" + "github.com/devsy-org/devsy/pkg/status" "github.com/devsy-org/devsy/pkg/stdio" "github.com/moby/patternmatcher/ignorefile" "google.golang.org/grpc" @@ -91,7 +92,7 @@ func RunSetupServer( } func New(options ...Option) *tunnelServer { - s := &tunnelServer{} + s := &tunnelServer{statusReporter: status.Nop()} for _, o := range options { s = o(s) } @@ -116,6 +117,8 @@ type tunnelServer struct { platformOptions *devsy.PlatformOptions secrets []*tunnel.Secret gitToken *provider2.GitToken + + statusReporter status.Reporter } func (t *tunnelServer) RunWithResult( @@ -429,6 +432,19 @@ func (t *tunnelServer) Log(ctx context.Context, message *tunnel.LogMessage) (*tu return &tunnel.Empty{}, nil } +func (t *tunnelServer) Status( + ctx context.Context, + update *tunnel.StatusUpdate, +) (*tunnel.Empty, error) { + t.statusReporter.Report(status.Event{ + Phase: status.Phase(update.Phase), + Step: update.Step, + Started: update.Started, + Err: update.Error, + }) + return &tunnel.Empty{}, nil +} + func (t *tunnelServer) StreamWorkspace( message *tunnel.Empty, stream tunnel.Tunnel_StreamWorkspaceServer, diff --git a/pkg/client/client.go b/pkg/client/client.go index 55fdc8ce1..e44775719 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -9,6 +9,7 @@ import ( "github.com/devsy-org/api/pkg/devsy" "github.com/devsy-org/devsy/pkg/devcontainer/config" "github.com/devsy-org/devsy/pkg/provider" + "github.com/devsy-org/devsy/pkg/status" "golang.org/x/crypto/ssh" ) @@ -162,10 +163,10 @@ type CommandOptions struct { type UpOptions struct { provider.CLIOptions - Debug bool - - Stdin io.Reader - Stdout io.Writer + Debug bool + Stdin io.Reader + Stdout io.Writer + Reporter status.Reporter } type SshOptions struct { @@ -178,10 +179,12 @@ type SshOptions struct { type Status string const ( - StatusRunning = "Running" - StatusBusy = "Busy" - StatusStopped = "Stopped" - StatusNotFound = "NotFound" + StatusRunning = "Running" + StatusBusy = "Busy" + StatusStopped = "Stopped" + StatusNotFound = "NotFound" + StatusProvisioning = "Provisioning" + StatusFailed = "Failed" ) func ParseStatus(in string) (Status, error) { @@ -195,11 +198,18 @@ func ParseStatus(in string) (Status, error) { return StatusStopped, nil case "NOTFOUND": return StatusNotFound, nil + case "PROVISIONING": + return StatusProvisioning, nil + case "FAILED": + return StatusFailed, nil default: return StatusNotFound, fmt.Errorf( "error parsing status: %q unrecognized status, needs to be one of: %v", in, - []string{StatusRunning, StatusBusy, StatusStopped, StatusNotFound}, + []string{ + StatusRunning, StatusBusy, StatusStopped, StatusNotFound, + StatusProvisioning, StatusFailed, + }, ) } } diff --git a/pkg/client/clientimplementation/daemonclient/stop.go b/pkg/client/clientimplementation/daemonclient/stop.go index 595705bf7..65faee2cb 100644 --- a/pkg/client/clientimplementation/daemonclient/stop.go +++ b/pkg/client/clientimplementation/daemonclient/stop.go @@ -8,6 +8,7 @@ import ( managementv1 "github.com/devsy-org/api/pkg/apis/management/v1" clientpkg "github.com/devsy-org/devsy/pkg/client" "github.com/devsy-org/devsy/pkg/platform" + "github.com/devsy-org/devsy/pkg/status" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -47,7 +48,7 @@ func (c *client) Stop(ctx context.Context, opt clientpkg.StopOptions) error { return fmt.Errorf("no stop task id returned from server") } - _, err = observeTask(ctx, managementClient, workspace, retStop.Status.TaskID) + _, err = observeTask(ctx, managementClient, workspace, retStop.Status.TaskID, status.Nop()) if err != nil { return fmt.Errorf("stop: %w", err) } diff --git a/pkg/client/clientimplementation/daemonclient/up.go b/pkg/client/clientimplementation/daemonclient/up.go index 0545a1710..14bd8572a 100644 --- a/pkg/client/clientimplementation/daemonclient/up.go +++ b/pkg/client/clientimplementation/daemonclient/up.go @@ -2,6 +2,7 @@ package daemonclient import ( "bufio" + "bytes" "context" "encoding/json" "errors" @@ -21,6 +22,7 @@ import ( "github.com/devsy-org/devsy/pkg/platform" platformclient "github.com/devsy-org/devsy/pkg/platform/client" "github.com/devsy-org/devsy/pkg/platform/kube" + "github.com/devsy-org/devsy/pkg/status" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -65,7 +67,7 @@ func (c *client) Up(ctx context.Context, opt clientpkg.UpOptions) (*config.Resul return nil, err } - return waitTaskDone(ctx, managementClient, instance, taskID) + return waitTaskDone(ctx, managementClient, instance, taskID, reporterOrNop(opt.Reporter)) } func migratedRebuildError( @@ -152,6 +154,13 @@ func startUpTask( return managementClient, taskID, nil } +func reporterOrNop(r status.Reporter) status.Reporter { + if r == nil { + return status.Nop() + } + return r +} + type createUpTaskParams struct { managementClient kube.Interface instance *managementv1.DevsyWorkspaceInstance @@ -208,8 +217,9 @@ func waitTaskDone( managementClient kube.Interface, instance *managementv1.DevsyWorkspaceInstance, taskID string, + reporter status.Reporter, ) (*config.Result, error) { - exitCode, err := observeTask(ctx, managementClient, instance, taskID) + exitCode, err := observeTask(ctx, managementClient, instance, taskID, reporter) if err != nil { return nil, fmt.Errorf("up: %w", err) } else if exitCode != 0 { @@ -284,6 +294,7 @@ func observeTask( managementClient kube.Interface, instance *managementv1.DevsyWorkspaceInstance, taskID string, + reporter status.Reporter, ) (int, error) { var ( exitCode int @@ -318,7 +329,7 @@ func observeTask( } }() go func() { - exitCode, err = printLogs(printCtx, managementClient, instance, taskID) + exitCode, err = printLogs(printCtx, managementClient, instance, taskID, reporter) errChan <- err }() @@ -344,6 +355,7 @@ func printLogs( managementClient kube.Interface, workspace *managementv1.DevsyWorkspaceInstance, taskID string, + reporter status.Reporter, ) (int, error) { // get logs reader log.Debugf("printing logs of task: %s", taskID) @@ -384,6 +396,12 @@ func printLogs( <-stderrDone }() + // The remote task runs the same devsy CLI, so its stdout carries the + // same NDJSON status lines a local `up` does; sniff them out here. + statusWriter := newStatusSniffingWriter(stdoutStreamer, reporter) + defer func() { _ = statusWriter.Close() }() + stdout := io.Writer(statusWriter) + // loop over all lines for scanner.Scan() { line := scanner.Text() @@ -394,7 +412,7 @@ func printLogs( return -1, fmt.Errorf("error parsing JSON from logs reader: %w, line: %s", err, line) } - exitCode, done, err := writeMessage(stdoutStreamer, stderrStreamer, message) + exitCode, done, err := writeMessage(stdout, stderrStreamer, message) if done { return exitCode, err } @@ -429,6 +447,57 @@ func writeMessage(stdout, stderr io.Writer, message *Message) (int, bool, error) return 0, false, nil } +// statusSniffingWriter splits a byte stream into lines, forwards each +// status NDJSON envelope line to reporter, and passes every other line +// through to next unchanged. +type statusSniffingWriter struct { + next io.Writer + reporter status.Reporter + buf bytes.Buffer +} + +func newStatusSniffingWriter(next io.Writer, reporter status.Reporter) *statusSniffingWriter { + return &statusSniffingWriter{next: next, reporter: reporter} +} + +func (w *statusSniffingWriter) Write(p []byte) (int, error) { + w.buf.Write(p) + for { + line, err := w.buf.ReadString('\n') + if err != nil { + // Incomplete line: put it back for the next Write/Close. + w.buf.Reset() + w.buf.WriteString(line) + break + } + if event, ok := config.ParseStatusLine(line); ok { + w.reporter.Report(event) + continue + } + if _, err := w.next.Write([]byte(line)); err != nil { + return len(p), err + } + } + return len(p), nil +} + +// Close flushes any trailing bytes left without a newline, sniffing them the +// same way Write does so a final status envelope on an unterminated line is +// reported rather than leaking into the caller's stream as raw JSON. +func (w *statusSniffingWriter) Close() error { + if w.buf.Len() == 0 { + return nil + } + trailing := w.buf.String() + w.buf.Reset() + if event, ok := config.ParseStatusLine(trailing); ok { + w.reporter.Report(event) + return nil + } + _, err := w.next.Write([]byte(trailing)) + return err +} + const ( TaskStatusRunning = "Running" TaskStatusSucceed = "Succeeded" diff --git a/pkg/client/clientimplementation/daemonclient/up_test.go b/pkg/client/clientimplementation/daemonclient/up_test.go new file mode 100644 index 000000000..69299bcc7 --- /dev/null +++ b/pkg/client/clientimplementation/daemonclient/up_test.go @@ -0,0 +1,106 @@ +package daemonclient + +import ( + "bytes" + "testing" + + "github.com/devsy-org/devsy/pkg/status" +) + +type recordingReporter struct { + events []status.Event +} + +func (r *recordingReporter) Report(e status.Event) { + r.events = append(r.events, e) +} + +func TestStatusSniffingWriter_ForwardsPlainLogLines(t *testing.T) { + var next bytes.Buffer + reporter := &recordingReporter{} + w := newStatusSniffingWriter(&next, reporter) + + _, err := w.Write([]byte("hello\nworld\n")) + if err != nil { + t.Fatalf("write: %v", err) + } + if err := w.Close(); err != nil { + t.Fatalf("close: %v", err) + } + + if got := next.String(); got != "hello\nworld\n" { + t.Errorf("next = %q, want %q", got, "hello\nworld\n") + } + if len(reporter.events) != 0 { + t.Errorf("expected no status events, got %d", len(reporter.events)) + } +} + +func TestStatusSniffingWriter_ExtractsStatusLines(t *testing.T) { + var next bytes.Buffer + reporter := &recordingReporter{} + w := newStatusSniffingWriter(&next, reporter) + + input := `before +{"kind":"status","phase":"building_image","started":true} +after +` + if _, err := w.Write([]byte(input)); err != nil { + t.Fatalf("write: %v", err) + } + if err := w.Close(); err != nil { + t.Fatalf("close: %v", err) + } + + if got := next.String(); got != "before\nafter\n" { + t.Errorf("next = %q, want %q", got, "before\nafter\n") + } + if len(reporter.events) != 1 { + t.Fatalf("expected 1 status event, got %d", len(reporter.events)) + } + e := reporter.events[0] + if e.Phase != status.PhaseBuildingImage || !e.Started { + t.Errorf("unexpected event: %+v", e) + } +} + +func TestStatusSniffingWriter_FlushesPartialLineOnClose(t *testing.T) { + var next bytes.Buffer + w := newStatusSniffingWriter(&next, &recordingReporter{}) + + if _, err := w.Write([]byte("no newline yet")); err != nil { + t.Fatalf("write: %v", err) + } + if next.Len() != 0 { + t.Errorf("expected nothing forwarded before close, got %q", next.String()) + } + if err := w.Close(); err != nil { + t.Fatalf("close: %v", err) + } + if got := next.String(); got != "no newline yet" { + t.Errorf("next = %q, want %q", got, "no newline yet") + } +} + +func TestStatusSniffingWriter_SplitAcrossWrites(t *testing.T) { + var next bytes.Buffer + reporter := &recordingReporter{} + w := newStatusSniffingWriter(&next, reporter) + + if _, err := w.Write([]byte(`{"kind":"status","phase":"read`)); err != nil { + t.Fatalf("write: %v", err) + } + if _, err := w.Write([]byte(`y","started":false}` + "\n")); err != nil { + t.Fatalf("write: %v", err) + } + if err := w.Close(); err != nil { + t.Fatalf("close: %v", err) + } + + if next.Len() != 0 { + t.Errorf("expected nothing forwarded, got %q", next.String()) + } + if len(reporter.events) != 1 || reporter.events[0].Phase != status.PhaseReady { + t.Errorf("unexpected events: %+v", reporter.events) + } +} diff --git a/pkg/client/clientimplementation/workspace_client.go b/pkg/client/clientimplementation/workspace_client.go index 273a3d1c1..4525a4f02 100644 --- a/pkg/client/clientimplementation/workspace_client.go +++ b/pkg/client/clientimplementation/workspace_client.go @@ -24,6 +24,8 @@ import ( "github.com/devsy-org/devsy/pkg/provider" "github.com/devsy-org/devsy/pkg/shell" "github.com/devsy-org/devsy/pkg/ssh" + "github.com/devsy-org/devsy/pkg/status" + "github.com/devsy-org/devsy/pkg/task" "github.com/devsy-org/devsy/pkg/types" "github.com/gofrs/flock" ) @@ -290,15 +292,26 @@ func (s *workspaceClient) Status( s.m.Lock() defer s.m.Unlock() - if s.isMachineProvider() && len(s.config.Exec.Status) > 0 { - return s.machineStatus(ctx, opt) + var ( + result client.Status + err error + ) + switch { + case s.isMachineProvider() && len(s.config.Exec.Status) > 0: + result, err = s.machineStatus(ctx, opt) + case opt.ContainerStatus: + result, err = s.getContainerStatus(ctx) + default: + result, err = s.workspaceFolderStatus() } - - if opt.ContainerStatus { - return s.getContainerStatus(ctx) + if err != nil || result != client.StatusNotFound { + return result, err } - return s.workspaceFolderStatus() + if override, ok := s.taskStatusOverride(); ok { + return override, nil + } + return result, nil } func (s *workspaceClient) Describe(ctx context.Context) (string, error) { @@ -320,6 +333,57 @@ func (s *workspaceClient) Describe(ctx context.Context) (string, error) { return machineClient.Describe(ctx) } +// taskStatusOverride reports Provisioning or Failed based on the most +// recently started `up` task for this workspace. +func (s *workspaceClient) taskStatusOverride() (client.Status, bool) { + latest := s.latestUpTask() + if latest == nil { + return "", false + } + + switch { + case !latest.Status.Terminal(): + return client.StatusProvisioning, true + case latest.Status == task.StatusFailed: + return client.StatusFailed, true + default: + return "", false + } +} + +// latestUpTask returns the most recently started `up` task recorded for this +// workspace, or nil if none exists or task state can't be read. +func (s *workspaceClient) latestUpTask() *task.State { + store, err := task.NewStore() + if err != nil { + return nil + } + states, err := store.List() + if err != nil { + return nil + } + + latest := newestUpTask(states, s.workspace.ID) + if latest == nil { + return nil + } + return store.Reconcile(latest) +} + +// newestUpTask picks the most recently started `up` task for workspaceID. +func newestUpTask(states []*task.State, workspaceID string) *task.State { + var latest *task.State + for _, st := range states { + if st.WorkspaceID != workspaceID || st.Command != "up" { + continue + } + if latest == nil || st.StartedAt.After(latest.StartedAt) { + latest = st + } + } + return latest +} + func (s *workspaceClient) agentConfig() provider.ProviderAgentConfig { return options.ResolveAgentConfig(s.devsyConfig, s.config, s.workspace, s.machine) } @@ -1092,6 +1156,12 @@ func runTunnelServer( opts BuildAgentClientOptions, stdoutReader, stdinWriter *os.File, ) (*config2.Result, error) { + tunnelOptions := []tunnelserver.Option{ + tunnelserver.WithStatusReporter(status.NewLogReporter()), + tunnelserver.WithGitToken(opts.CLIOptions.GitToken), + } + tunnelOptions = append(tunnelOptions, opts.TunnelOptions...) + result, err := tunnelserver.RunUpServer( ctx, stdoutReader, @@ -1099,8 +1169,7 @@ func runTunnelServer( opts.WorkspaceClient.AgentInjectGitCredentials(opts.CLIOptions), opts.WorkspaceClient.AgentInjectDockerCredentials(opts.CLIOptions), opts.WorkspaceClient.WorkspaceConfig(), - append(opts.TunnelOptions, - tunnelserver.WithGitToken(opts.CLIOptions.GitToken))..., + tunnelOptions..., ) if err != nil { return nil, fmt.Errorf("run tunnel server: %w", err) diff --git a/pkg/client/clientimplementation/workspace_client_status_test.go b/pkg/client/clientimplementation/workspace_client_status_test.go new file mode 100644 index 000000000..a6ec94303 --- /dev/null +++ b/pkg/client/clientimplementation/workspace_client_status_test.go @@ -0,0 +1,158 @@ +package clientimplementation + +import ( + "errors" + "testing" + "time" + + "github.com/devsy-org/devsy/pkg/client" + "github.com/devsy-org/devsy/pkg/config" + "github.com/devsy-org/devsy/pkg/provider" + "github.com/devsy-org/devsy/pkg/task" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const testWorkspaceID = "my-ws" + +// fakeTaskDirPathManager overrides only TaskDir so tests can point pkg/task +// at a temp directory without touching the real state dir. +type fakeTaskDirPathManager struct { + config.PathManager + dir string +} + +func (f fakeTaskDirPathManager) TaskDir() (string, error) { return f.dir, nil } + +func useTempTaskDir(t *testing.T) { + t.Helper() + config.SetPathManager(fakeTaskDirPathManager{ + PathManager: config.NewPathManager(), + dir: t.TempDir(), + }) + t.Cleanup(config.ResetPathManager) +} + +func assertOverride(t *testing.T, s *workspaceClient, wantOK bool, wantStatus client.Status) { + t.Helper() + got, ok := s.taskStatusOverride() + assert.Equal(t, wantOK, ok) + if wantOK { + assert.Equal(t, wantStatus, got) + } +} + +func TestTaskStatusOverride_NoTasks(t *testing.T) { + useTempTaskDir(t) + s := &workspaceClient{workspace: &provider.Workspace{ID: testWorkspaceID}} + assertOverride(t, s, false, "") +} + +func TestTaskStatusOverride_ActiveUpTaskReportsProvisioning(t *testing.T) { + useTempTaskDir(t) + store, err := task.NewStore() + require.NoError(t, err) + _, err = store.Create(task.CreateOptions{Command: "up", WorkspaceID: testWorkspaceID}) + require.NoError(t, err) + + s := &workspaceClient{workspace: &provider.Workspace{ID: testWorkspaceID}} + assertOverride(t, s, true, client.StatusProvisioning) +} + +func TestTaskStatusOverride_FailedTaskReportsFailed(t *testing.T) { + useTempTaskDir(t) + store, err := task.NewStore() + require.NoError(t, err) + tsk, err := store.Create(task.CreateOptions{Command: "up", WorkspaceID: testWorkspaceID}) + require.NoError(t, err) + require.NoError(t, tsk.Fail(errors.New("build failed"))) + + s := &workspaceClient{workspace: &provider.Workspace{ID: testWorkspaceID}} + assertOverride(t, s, true, client.StatusFailed) +} + +func TestTaskStatusOverride_SucceededTaskDefersToContainerStatus(t *testing.T) { + useTempTaskDir(t) + store, err := task.NewStore() + require.NoError(t, err) + tsk, err := store.Create(task.CreateOptions{Command: "up", WorkspaceID: testWorkspaceID}) + require.NoError(t, err) + require.NoError(t, tsk.Succeed(nil)) + + s := &workspaceClient{workspace: &provider.Workspace{ID: testWorkspaceID}} + assertOverride(t, s, false, "") +} + +func TestTaskStatusOverride_IgnoresOtherWorkspaces(t *testing.T) { + useTempTaskDir(t) + store, err := task.NewStore() + require.NoError(t, err) + _, err = store.Create(task.CreateOptions{Command: "up", WorkspaceID: "other-ws"}) + require.NoError(t, err) + + s := &workspaceClient{workspace: &provider.Workspace{ID: testWorkspaceID}} + assertOverride(t, s, false, "") +} + +func TestTaskStatusOverride_IgnoresNonUpCommands(t *testing.T) { + useTempTaskDir(t) + store, err := task.NewStore() + require.NoError(t, err) + _, err = store.Create(task.CreateOptions{Command: "delete", WorkspaceID: testWorkspaceID}) + require.NoError(t, err) + + s := &workspaceClient{workspace: &provider.Workspace{ID: testWorkspaceID}} + assertOverride(t, s, false, "") +} + +func TestTaskStatusOverride_MostRecentTaskWins(t *testing.T) { + useTempTaskDir(t) + store, err := task.NewStore() + require.NoError(t, err) + + older, err := store.Create(task.CreateOptions{Command: "up", WorkspaceID: testWorkspaceID}) + require.NoError(t, err) + require.NoError(t, older.Fail(errors.New("first attempt failed"))) + + time.Sleep(5 * time.Millisecond) + _, err = store.Create(task.CreateOptions{Command: "up", WorkspaceID: testWorkspaceID}) + require.NoError(t, err) + + s := &workspaceClient{workspace: &provider.Workspace{ID: testWorkspaceID}} + assertOverride(t, s, true, client.StatusProvisioning) +} + +func TestTaskStatusOverride_AbandonedTaskReportsFailed(t *testing.T) { + useTempTaskDir(t) + store, err := task.NewStore() + require.NoError(t, err) + tk, err := store.Create(task.CreateOptions{Command: "up", WorkspaceID: testWorkspaceID}) + require.NoError(t, err) + // Claim then release the worker lock: the kernel frees a dead worker's + // lock the same way, which is what marks the task abandoned. + require.NoError(t, tk.HoldWorkerLock()) + require.NoError(t, tk.ReleaseWorkerLockForTest()) + + s := &workspaceClient{workspace: &provider.Workspace{ID: testWorkspaceID}} + assertOverride(t, s, true, client.StatusFailed) + + // Persisted, so `task list` agrees with what Status just reported. + state, err := store.Get(tk.ID()) + require.NoError(t, err) + assert.Equal(t, task.StatusFailed, state.Status) +} + +func TestTaskStatusOverride_LiveWorkerStillReportsProvisioning(t *testing.T) { + useTempTaskDir(t) + store, err := task.NewStore() + require.NoError(t, err) + tk, err := store.Create(task.CreateOptions{Command: "up", WorkspaceID: testWorkspaceID}) + require.NoError(t, err) + require.NoError(t, tk.HoldWorkerLock()) + // Released on cleanup so the fd closes even if an assertion fails; an open + // file can block TempDir removal on some platforms. + t.Cleanup(func() { require.NoError(t, tk.ReleaseWorkerLockForTest()) }) + + s := &workspaceClient{workspace: &provider.Workspace{ID: testWorkspaceID}} + assertOverride(t, s, true, client.StatusProvisioning) +} diff --git a/pkg/command/process_supported.go b/pkg/command/process_supported.go index d35668b7f..4ec57a98a 100644 --- a/pkg/command/process_supported.go +++ b/pkg/command/process_supported.go @@ -3,6 +3,7 @@ package command import ( + "errors" "os" "strconv" "syscall" @@ -34,8 +35,16 @@ func kill(pid string) error { return err } - _ = syscall.Kill(parsedPid, syscall.SIGTERM) + if err := syscall.Kill(parsedPid, syscall.SIGTERM); err != nil { + if errors.Is(err, syscall.ESRCH) { + return nil // already exited + } + return err + } time.Sleep(2 * time.Second) - _ = syscall.Kill(parsedPid, syscall.SIGKILL) + err = syscall.Kill(parsedPid, syscall.SIGKILL) + if err != nil && !errors.Is(err, syscall.ESRCH) { + return err + } return nil } diff --git a/pkg/command/process_test.go b/pkg/command/process_test.go new file mode 100644 index 000000000..8e53bfccc --- /dev/null +++ b/pkg/command/process_test.go @@ -0,0 +1,49 @@ +package command + +import ( + "os/exec" + "strconv" + "testing" + "time" +) + +func TestKillTerminatesRunningProcess(t *testing.T) { + cmd := exec.Command("sleep", "30") + if err := cmd.Start(); err != nil { + t.Fatalf("start: %v", err) + } + pid := strconv.Itoa(cmd.Process.Pid) + + if err := Kill(pid); err != nil { + t.Fatalf("Kill: %v", err) + } + + done := make(chan struct{}) + go func() { + _ = cmd.Wait() + close(done) + }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Error("process still running after Kill") + } +} + +func TestKillOnAlreadyExitedProcessIsNoop(t *testing.T) { + cmd := exec.Command("true") + if err := cmd.Run(); err != nil { + t.Fatalf("run: %v", err) + } + pid := strconv.Itoa(cmd.Process.Pid) + + if err := Kill(pid); err != nil { + t.Errorf("Kill on exited process = %v, want nil", err) + } +} + +func TestKillInvalidPIDReturnsError(t *testing.T) { + if err := Kill("not-a-pid"); err == nil { + t.Error("Kill(\"not-a-pid\") = nil, want error") + } +} diff --git a/pkg/compose/helper_test.go b/pkg/compose/helper_test.go index e054e42a8..b14d58364 100644 --- a/pkg/compose/helper_test.go +++ b/pkg/compose/helper_test.go @@ -2,8 +2,10 @@ package compose import ( "context" + "os/exec" "strings" "testing" + "time" "github.com/devsy-org/devsy/pkg/docker" "github.com/stretchr/testify/suite" @@ -183,6 +185,13 @@ func (r stubRuntime) GPUAvailable(_ context.Context, _ *docker.DockerHelper) (bo } func (s *HelperTestSuite) TestNewComposeHelperPodmanRuntimeUsesDockerCommand() { + // Skip for local development when I do not have Podman machine running + probeCtx, cancelProbe := context.WithTimeout(context.Background(), 15*time.Second) + defer cancelProbe() + if exec.CommandContext(probeCtx, testPodmanCmd, "compose", "version").Run() != nil { + s.T().Skip("podman compose not reachable (is podman machine running?)") + } + helper := &docker.DockerHelper{ DockerCommand: "podman", Runtime: stubRuntime{name: docker.RuntimePodman}, @@ -267,8 +276,5 @@ func (s *HelperTestSuite) TestNewComposeHelperNonPodmanFallbackUsesPodman() { s.T().Skipf("no compose binary available in test environment: %v", err) } - // When Docker runtime succeeds, it should use testDockerCmd — but if Docker Compose V2 - // is unavailable, the fallback should independently probe "podman", not re-try testDockerCmd. - // We verify here that the successful helper uses a valid command. s.Contains([]string{testDockerCmd, testPodmanCmd, testDockerComposeCmd}, ch.Command) } diff --git a/pkg/config/pathmanager.go b/pkg/config/pathmanager.go index 1301bab4a..df279aeb6 100644 --- a/pkg/config/pathmanager.go +++ b/pkg/config/pathmanager.go @@ -88,6 +88,7 @@ type PathManager interface { // State sub-paths. LogDir() (string, error) + TaskDir() (string, error) } // basePathManager implements every sub-path method by delegating the top-level @@ -384,7 +385,15 @@ func (b *basePathManager) LogDir() (string, error) { return filepath.Join(dir, "logs"), nil } -// --- Singleton management --- +// TaskDir holds state files for detached background tasks. +func (b *basePathManager) TaskDir() (string, error) { + dir, err := b.pm.StateDir() + if err != nil { + return "", err + } + + return filepath.Join(dir, "tasks"), nil +} var ( defaultPM PathManager diff --git a/pkg/devcontainer/build.go b/pkg/devcontainer/build.go index 76989151e..8e3506386 100644 --- a/pkg/devcontainer/build.go +++ b/pkg/devcontainer/build.go @@ -19,6 +19,7 @@ import ( "github.com/devsy-org/devsy/pkg/image" "github.com/devsy-org/devsy/pkg/log" "github.com/devsy-org/devsy/pkg/provider" + "github.com/devsy-org/devsy/pkg/status" ) func (r *runner) build( @@ -460,7 +461,14 @@ func (r *runner) buildImage( } } - return r.executeBuild(ctx, params, prebuildHash, targetArch) + status.Enter(r.reporter, status.PhaseBuildingImage, "") + buildInfo, err := r.executeBuild(ctx, params, prebuildHash, targetArch) + if err != nil { + status.Fail(r.reporter, status.PhaseBuildingImage, err) + return nil, err + } + status.Leave(r.reporter, status.PhaseBuildingImage, "") + return buildInfo, nil } // executeBuild dispatches the actual image build to the appropriate backend: diff --git a/pkg/devcontainer/config/envelope.go b/pkg/devcontainer/config/envelope.go index ac604da7c..8a0ae12f7 100644 --- a/pkg/devcontainer/config/envelope.go +++ b/pkg/devcontainer/config/envelope.go @@ -4,9 +4,20 @@ import ( "encoding/json" "fmt" "io" + "strings" + + "github.com/devsy-org/devsy/pkg/status" +) + +const ( + KindStatus = "status" + KindResult = "result" + KindError = "error" + KindTask = "task" ) type ResultEnvelope struct { + Kind string `json:"kind"` Outcome string `json:"outcome"` ContainerID string `json:"containerId"` RemoteUser string `json:"remoteUser"` @@ -17,14 +28,40 @@ type ResultEnvelope struct { } type ErrorEnvelope struct { + Kind string `json:"kind"` Outcome string `json:"outcome"` Message string `json:"message"` } -// WriteResultJSON serializes env as a success envelope to w. The caller -// supplies the envelope fields; this function stamps Outcome="success" and -// appends a trailing newline. +// StatusEnvelope is one NDJSON line reporting a phase transition of the up +// pipeline. +type StatusEnvelope struct { + Kind string `json:"kind"` + Phase string `json:"phase"` + Step string `json:"step,omitempty"` + Started bool `json:"started"` + Error string `json:"error,omitempty"` +} + +// TaskEnvelope is the single line `up --detach` writes to stdout. +type TaskEnvelope struct { + Kind string `json:"kind"` + ID string `json:"id"` +} + +// WriteTaskJSON serializes a submitted task's ID as an NDJSON line to w. +func WriteTaskJSON(w io.Writer, id string) error { + data, err := json.Marshal(TaskEnvelope{Kind: KindTask, ID: id}) + if err != nil { + return err + } + _, err = fmt.Fprintf(w, "%s\n", data) + return err +} + +// WriteResultJSON serializes env as a success envelope. func WriteResultJSON(w io.Writer, env ResultEnvelope) error { + env.Kind = KindResult env.Outcome = "success" data, err := json.Marshal(env) if err != nil { @@ -36,6 +73,7 @@ func WriteResultJSON(w io.Writer, env ResultEnvelope) error { func WriteErrorJSON(w io.Writer, msg string) error { env := ErrorEnvelope{ + Kind: KindError, Outcome: "error", Message: msg, } @@ -46,3 +84,38 @@ func WriteErrorJSON(w io.Writer, msg string) error { _, err = fmt.Fprintf(w, "%s\n", data) return err } + +// ParseStatusLine parses line as a status NDJSON envelope. +func ParseStatusLine(line string) (status.Event, bool) { + trimmed := strings.TrimSpace(line) + if !strings.HasPrefix(trimmed, "{") { + return status.Event{}, false + } + var env StatusEnvelope + if err := json.Unmarshal([]byte(trimmed), &env); err != nil || env.Kind != KindStatus { + return status.Event{}, false + } + return status.Event{ + Phase: status.Phase(env.Phase), + Step: env.Step, + Started: env.Started, + Err: env.Error, + }, true +} + +// WriteStatusJSON serializes a status.Event as an NDJSON status line. +func WriteStatusJSON(w io.Writer, e status.Event) error { + env := StatusEnvelope{ + Kind: KindStatus, + Phase: string(e.Phase), + Step: e.Step, + Started: e.Started, + Error: e.Err, + } + data, err := json.Marshal(env) + if err != nil { + return err + } + _, err = fmt.Fprintf(w, "%s\n", data) + return err +} diff --git a/pkg/devcontainer/feature/extend.go b/pkg/devcontainer/feature/extend.go index 3b6aa08d2..5adafce31 100644 --- a/pkg/devcontainer/feature/extend.go +++ b/pkg/devcontainer/feature/extend.go @@ -1,6 +1,7 @@ package feature import ( + "context" "fmt" "os" "path" @@ -15,6 +16,7 @@ import ( "github.com/devsy-org/devsy/pkg/devcontainer/metadata" "github.com/devsy-org/devsy/pkg/log" "github.com/google/go-containerregistry/pkg/name" + "golang.org/x/sync/errgroup" ) var ( @@ -368,18 +370,43 @@ func prepareLock( return lock, nil } +// getUserFeatures resolves every feature the user configured directly. +// Each resolution can involve a network pull (OCI) or tarball download, so +// they run concurrently rather than one at a time. func getUserFeatures( processor *featureProcessor, devContainerConfig *config.DevContainerConfig, ) (map[string]*config.FeatureSet, error) { - userFeatures := map[string]*config.FeatureSet{} + type resolved struct { + key string + featureSet *config.FeatureSet + } + + g, _ := errgroup.WithContext(context.Background()) + results := make([]resolved, len(devContainerConfig.Features)) + i := 0 for featureID, featureOptions := range devContainerConfig.Features { - featureSet, err := processor.processFeature(featureID, featureOptions) - if err != nil { - return nil, fmt.Errorf("process feature %s: %w", featureID, err) - } - key := featureDeduplicationKey(featureSet.ConfigID, featureSet.Version) - userFeatures[key] = featureSet + idx := i + i++ + g.Go(func() error { + featureSet, err := processor.processFeature(featureID, featureOptions) + if err != nil { + return fmt.Errorf("process feature %s: %w", featureID, err) + } + results[idx] = resolved{ + key: featureDeduplicationKey(featureSet.ConfigID, featureSet.Version), + featureSet: featureSet, + } + return nil + }) + } + if err := g.Wait(); err != nil { + return nil, err + } + + userFeatures := make(map[string]*config.FeatureSet, len(results)) + for _, r := range results { + userFeatures[r.key] = r.featureSet } return userFeatures, nil } diff --git a/pkg/devcontainer/feature/lockfile.go b/pkg/devcontainer/feature/lockfile.go index 61483daf6..767d3be2e 100644 --- a/pkg/devcontainer/feature/lockfile.go +++ b/pkg/devcontainer/feature/lockfile.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "strings" + "sync" "github.com/devsy-org/devsy/pkg/devcontainer/config" "github.com/devsy-org/devsy/pkg/log" @@ -153,6 +154,7 @@ type lockfileMode struct { // entries resolved during a fetch so they can be written afterwards. type lockfileState struct { loaded *Lockfile + mu sync.Mutex entries map[string]LockedFeature } @@ -174,7 +176,9 @@ func (l *lockfileState) record(featureID string, entry LockedFeature) { if l == nil { return } + l.mu.Lock() l.entries[featureID] = entry + l.mu.Unlock() } // newLockfileState loads the lockfile for the given config to enable pinning. diff --git a/pkg/devcontainer/run.go b/pkg/devcontainer/run.go index ea678cb35..cec1a8680 100644 --- a/pkg/devcontainer/run.go +++ b/pkg/devcontainer/run.go @@ -14,11 +14,17 @@ import ( "github.com/devsy-org/devsy/pkg/language" "github.com/devsy-org/devsy/pkg/log" "github.com/devsy-org/devsy/pkg/provider" + "github.com/devsy-org/devsy/pkg/status" ) // Runner drives the lifecycle of a single workspace's dev container. type Runner interface { - Up(ctx context.Context, options UpOptions, timeout time.Duration) (*config.Result, error) + Up( + ctx context.Context, + options UpOptions, + timeout time.Duration, + reporter status.Reporter, + ) (*config.Result, error) Build(ctx context.Context, options provider.BuildOptions) (string, error) Find(ctx context.Context) (*config.ContainerDetails, error) Command(ctx context.Context, params CommandParams) error @@ -83,6 +89,8 @@ type runner struct { idLabels []string recovering bool + + reporter status.Reporter } func NewRunner( @@ -110,6 +118,7 @@ func NewRunner( id: GetRunnerIDFromWorkspace(workspaceConfig.Workspace), idLabels: workspaceConfig.CLIOptions.IDLabels, workspaceConfig: workspaceConfig, + reporter: status.Nop(), }, nil } @@ -124,25 +133,38 @@ func (r *runner) Up( ctx context.Context, options UpOptions, timeout time.Duration, + reporter status.Reporter, ) (*config.Result, error) { + if reporter == nil { + reporter = status.Nop() + } + r.reporter = reporter + log.Debugf( "Up devcontainer for workspace %q with timeout %s", r.workspaceConfig.Workspace.ID, timeout, ) + status.Enter(reporter, status.PhaseResolvingConfig, "") substitutedConfig, substitutionContext, err := r.getSubstitutedConfig(options.CLIOptions) if err != nil { + status.Fail(reporter, status.PhaseResolvingConfig, err) return nil, err } + status.Leave(reporter, status.PhaseResolvingConfig, "") defer cleanupBuildInformation(substitutedConfig.Config) // Recovery skips initializeCommand: a failing host hook must not block the - // recovery container. In normal mode its failure is recovery-eligible. + // recovery container. if !options.Recovery { + status.Enter(reporter, status.PhaseInitializeCommand, "") if err := r.runInitializeCommand(ctx, substitutedConfig.Config, options); err != nil { - return nil, clierr.Recoverable(fmt.Errorf("initialize command: %w", err)) + err = clierr.Recoverable(fmt.Errorf("initialize command: %w", err)) + status.Fail(reporter, status.PhaseInitializeCommand, err) + return nil, err } + status.Leave(reporter, status.PhaseInitializeCommand, "") } params := &runContainerParams{ @@ -156,6 +178,11 @@ func (r *runner) Up( if result != nil { result.RecoveryContainer = r.recovering } + if err != nil { + status.Fail(reporter, status.PhaseReady, err) + return result, err + } + status.Leave(reporter, status.PhaseReady, "") return result, err } diff --git a/pkg/devcontainer/setup.go b/pkg/devcontainer/setup.go index d01c8ac5b..9a96102f7 100644 --- a/pkg/devcontainer/setup.go +++ b/pkg/devcontainer/setup.go @@ -26,6 +26,7 @@ import ( "github.com/devsy-org/devsy/pkg/ide" "github.com/devsy-org/devsy/pkg/log" provider2 "github.com/devsy-org/devsy/pkg/provider" + "github.com/devsy-org/devsy/pkg/status" "github.com/devsy-org/devsy/pkg/types" ) @@ -72,9 +73,12 @@ func (r *runner) setupContainer( ctx context.Context, params *setupContainerParams, ) (*config.Result, error) { + status.Enter(r.reporter, status.PhaseInjectingAgent, "") if err := r.injectAgentIntoContainer(ctx, params.timeout); err != nil { + status.Fail(r.reporter, status.PhaseInjectingAgent, err) return nil, err } + status.Leave(r.reporter, status.PhaseInjectingAgent, "") log.Debugf("injected into container") defer log.Debugf("done setting up container") @@ -85,7 +89,14 @@ func (r *runner) setupContainer( setupCommand := r.buildSetupCommand(info.compressed, info.workspaceConfigCompressed) - return r.executeSetup(ctx, info.result, setupCommand) + status.Enter(r.reporter, status.PhaseRunningLifecycleHook, "") + result, err := r.executeSetup(ctx, info.result, setupCommand) + if err != nil { + status.Fail(r.reporter, status.PhaseRunningLifecycleHook, err) + return result, err + } + status.Leave(r.reporter, status.PhaseRunningLifecycleHook, "") + return result, nil } func (r *runner) injectAgentIntoContainer(ctx context.Context, timeout time.Duration) error { @@ -184,6 +195,24 @@ func (r *runner) deliverPostStart(ctx context.Context, strategy delivery.AgentDe return nil } +// prefetchAgentBinary warms the binary cache while the container builds. +func (r *runner) prefetchAgentBinary(ctx context.Context) { + arch, err := r.deliveryArch(ctx) + if err != nil { + return + } + binarySource, err := r.newBinarySource() + if err != nil { + return + } + rc, err := binarySource(ctx, arch) + if err != nil { + return + } + defer func() { _ = rc.Close() }() + _, _ = io.Copy(io.Discard, rc) +} + func (r *runner) newBinarySource() (delivery.BinarySourceFunc, error) { downloadURL := r.agentDownloadURL if downloadURL == "" { @@ -471,6 +500,7 @@ func (r *runner) executeSetup( r.workspaceConfig.CLIOptions.SecretsMount, ), tunnelserver.WithGitToken(r.workspaceConfig.CLIOptions.GitToken), + tunnelserver.WithStatusReporter(r.reporter), ) } diff --git a/pkg/devcontainer/single.go b/pkg/devcontainer/single.go index 00b606985..ce1767bde 100644 --- a/pkg/devcontainer/single.go +++ b/pkg/devcontainer/single.go @@ -18,6 +18,7 @@ import ( "github.com/devsy-org/devsy/pkg/driver" "github.com/devsy-org/devsy/pkg/language" "github.com/devsy-org/devsy/pkg/log" + "github.com/devsy-org/devsy/pkg/status" "github.com/devsy-org/devsy/pkg/telemetry/distinctid" ) @@ -80,11 +81,17 @@ func (r *runner) runSingleContainer( options: options, } + // Overlaps with the container build/start below instead of waiting. + go r.prefetchAgentBinary(ctx) + // Resolve container: ensure we have a running container with merged config. + status.Enter(r.reporter, status.PhaseStartingContainer, "") resolved, err := r.resolveContainer(ctx, params, containerDetails) if err != nil { + status.Fail(r.reporter, status.PhaseStartingContainer, err) return nil, err } + status.Leave(r.reporter, status.PhaseStartingContainer, "") return r.setupContainer(ctx, &setupContainerParams{ rawConfig: parsedConfig.Raw, diff --git a/pkg/flags/names/names.go b/pkg/flags/names/names.go index 1622e54c3..0931c88e9 100644 --- a/pkg/flags/names/names.go +++ b/pkg/flags/names/names.go @@ -18,6 +18,7 @@ const ( ContainerStatus = "container-status" ContainerUser = "container-user" Data = "data" + Detach = "detach" DevContainer = "devcontainer" DevContainerID = "devcontainer-id" DevContainerImage = "devcontainer-image" @@ -28,6 +29,7 @@ const ( FallbackImage = "fallback-image" Features = "features" FeatureSecretsFile = "feature-secrets-file" + Follow = "follow" Force = "force" GPUAvailability = "gpu-availability" GracePeriod = "grace-period" @@ -326,6 +328,7 @@ const ( SkipOnCreate = "skip-on-create" SkipUpdateContent = "skip-update-content" TargetURL = "target-url" + TaskID = "task-id" Workspace = "workspace" WorkspaceInfo = "workspace-info" WorkspaceProject = "workspace-project" diff --git a/pkg/status/log.go b/pkg/status/log.go new file mode 100644 index 000000000..9bbcba023 --- /dev/null +++ b/pkg/status/log.go @@ -0,0 +1,20 @@ +package status + +import "github.com/devsy-org/devsy/pkg/log" + +// logReporter renders events as debug log lines. +type logReporter struct{} + +// NewLogReporter returns a Reporter that logs each event at debug level. +func NewLogReporter() Reporter { return logReporter{} } + +func (logReporter) Report(e Event) { + switch { + case e.Phase == PhaseFailed: + log.Debugf("up: phase %q failed: %s", e.Step, e.Err) + case e.Started: + log.Debugf("up: entering phase %q", e.Phase) + default: + log.Debugf("up: completed phase %q", e.Phase) + } +} diff --git a/pkg/status/status.go b/pkg/status/status.go new file mode 100644 index 000000000..4b6ceaa77 --- /dev/null +++ b/pkg/status/status.go @@ -0,0 +1,67 @@ +// Package status defines the structured progress events emitted by +// long-running commands. +package status + +// Phase identifies a step in the up pipeline. +type Phase string + +const ( + PhaseCloningRepository Phase = "cloning_repository" + PhaseResolvingConfig Phase = "resolving_config" + PhaseInitializeCommand Phase = "initialize_command" + PhaseBuildingImage Phase = "building_image" + PhaseStartingContainer Phase = "starting_container" + PhaseInjectingAgent Phase = "injecting_agent" + PhaseRunningLifecycleHook Phase = "running_lifecycle_hook" + PhaseWaitingFor Phase = "waiting_for" + PhaseReady Phase = "ready" + PhaseFailed Phase = "failed" +) + +// Event is one phase transition. +type Event struct { + Phase Phase `json:"phase"` + Step string `json:"step,omitempty"` + Started bool `json:"started"` + Err string `json:"error,omitempty"` +} + +// Reporter receives status events as they occur. Implementations must be +// safe to call from goroutines. +type Reporter interface { + Report(Event) +} + +func Enter(r Reporter, phase Phase, step string) { + r.Report(Event{Phase: phase, Step: step, Started: true}) +} + +func Leave(r Reporter, phase Phase, step string) { + r.Report(Event{Phase: phase, Step: step, Started: false}) +} + +func Fail(r Reporter, phase Phase, err error) { + if err == nil { + return + } + r.Report(Event{Phase: PhaseFailed, Step: string(phase), Err: err.Error()}) +} + +type nopReporter struct{} + +func (nopReporter) Report(Event) {} + +func Nop() Reporter { return nopReporter{} } + +type teeReporter []Reporter + +func (t teeReporter) Report(e Event) { + for _, r := range t { + r.Report(e) + } +} + +// Tee forwards every event to each reporter. +func Tee(reporters ...Reporter) Reporter { + return teeReporter(reporters) +} diff --git a/pkg/status/status_test.go b/pkg/status/status_test.go new file mode 100644 index 000000000..521f82a0a --- /dev/null +++ b/pkg/status/status_test.go @@ -0,0 +1,66 @@ +package status + +import ( + "errors" + "testing" +) + +type recordingReporter struct { + events []Event +} + +func (r *recordingReporter) Report(e Event) { + r.events = append(r.events, e) +} + +func TestEnterLeave(t *testing.T) { + r := &recordingReporter{} + Enter(r, PhaseBuildingImage, "") + Leave(r, PhaseBuildingImage, "") + + if len(r.events) != 2 { + t.Fatalf("expected 2 events, got %d", len(r.events)) + } + if !r.events[0].Started || r.events[0].Phase != PhaseBuildingImage { + t.Errorf("unexpected enter event: %+v", r.events[0]) + } + if r.events[1].Started || r.events[1].Phase != PhaseBuildingImage { + t.Errorf("unexpected leave event: %+v", r.events[1]) + } +} + +func TestFail(t *testing.T) { + r := &recordingReporter{} + Fail(r, PhaseRunningLifecycleHook, errors.New("boom")) + + if len(r.events) != 1 { + t.Fatalf("expected 1 event, got %d", len(r.events)) + } + got := r.events[0] + wantStep := string(PhaseRunningLifecycleHook) + if got.Phase != PhaseFailed || got.Err != "boom" || got.Step != wantStep { + t.Errorf("unexpected fail event: %+v", got) + } +} + +func TestFailNilErrorIsNoop(t *testing.T) { + r := &recordingReporter{} + Fail(r, PhaseBuildingImage, nil) + + if len(r.events) != 0 { + t.Errorf("expected no event for nil error, got %+v", r.events) + } +} + +func TestNopDiscardsEvents(t *testing.T) { + Enter(Nop(), PhaseReady, "") +} + +func TestTeeForwardsToEachReporter(t *testing.T) { + a, b := &recordingReporter{}, &recordingReporter{} + Enter(Tee(a, b), PhaseReady, "") + + if len(a.events) != 1 || len(b.events) != 1 { + t.Errorf("expected both reporters to receive the event: a=%+v b=%+v", a.events, b.events) + } +} diff --git a/pkg/task/export_test.go b/pkg/task/export_test.go new file mode 100644 index 000000000..9ffbc395a --- /dev/null +++ b/pkg/task/export_test.go @@ -0,0 +1,7 @@ +package task + +// SetAfterClaimForTest runs fn inside Reconcile, just after it claims the dead +// worker's lock. +func (s *Store) SetAfterClaimForTest(fn func()) { + s.afterClaimForTest = fn +} diff --git a/pkg/task/store.go b/pkg/task/store.go new file mode 100644 index 000000000..59590b79d --- /dev/null +++ b/pkg/task/store.go @@ -0,0 +1,311 @@ +package task + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "time" + + "github.com/devsy-org/devsy/pkg/config" + "github.com/devsy-org/devsy/pkg/random" + "github.com/gofrs/flock" +) + +// lockTimeout bounds how long update waits to acquire a task's file lock. +const lockTimeout = 5 * time.Second + +// Store persists task state as one JSON file per task under dir. +type Store struct { + dir string + // Test seam; see Store.SetAfterClaimForTest. + afterClaimForTest func() +} + +func NewStore() (*Store, error) { + dir, err := config.DefaultPathManager().TaskDir() + if err != nil { + return nil, fmt.Errorf("task dir: %w", err) + } + return NewStoreAt(dir) +} + +func NewStoreAt(dir string) (*Store, error) { + if err := os.MkdirAll(dir, 0o750); err != nil { + return nil, fmt.Errorf("create task dir: %w", err) + } + return &Store{dir: dir}, nil +} + +func (s *Store) Create(opts CreateOptions) (*Task, error) { + id := random.String(12) + now := time.Now() + state := &State{ + ID: id, + Command: opts.Command, + WorkspaceID: opts.WorkspaceID, + Status: StatusPending, + StartedAt: now, + UpdatedAt: now, + } + if err := s.write(state); err != nil { + return nil, err + } + return &Task{store: s, id: id}, nil +} + +// Open returns a handle without reading the task's state. +func (s *Store) Open(id string) *Task { + return &Task{store: s, id: id} +} + +// Delete errors if the task is still pending or running unless force is set. +func (s *Store) Delete(id string, force bool) error { + path, err := s.path(id) + if err != nil { + return err + } + // Locked across the check and the removal so a concurrent update can't + // commit its atomic rename after the check and recreate the task. + return s.withLock(id, func() error { + if !force { + state, err := s.Get(id) + if err != nil { + return err + } + if !state.Status.Terminal() { + return fmt.Errorf( + "task %s is still %s; cancel it first or delete with force", + id, state.Status, + ) + } + } + if err := os.Remove(path); err != nil { + return fmt.Errorf("delete task %s: %w", id, err) + } + return nil + }) +} + +func (s *Store) Get(id string) (*State, error) { + path, err := s.path(id) + if err != nil { + return nil, err + } + data, err := os.ReadFile(path) // #nosec G304 -- path validated by s.path + if err != nil { + return nil, fmt.Errorf("read task %s: %w", id, err) + } + state := &State{} + if err := json.Unmarshal(data, state); err != nil { + return nil, fmt.Errorf("parse task %s: %w", id, err) + } + return state, nil +} + +// Abandoned reports whether a non-terminal task's worker is gone. +func (s *Store) Abandoned(state *State) bool { + if state == nil || state.Status.Terminal() { + return false + } + lock, ok := s.claimDeadWorkerLock(state.ID) + if !ok { + return false + } + _ = lock.Unlock() + return true +} + +// Reconcile marks a task failed when its worker died without recording a +// result, so it stops being reported as still running. Returns the effective +// state, which is unchanged for live and already-terminal tasks. +func (s *Store) Reconcile(state *State) *State { + if state == nil || state.Status.Terminal() { + return state + } + + // Held across the whole transition: releasing before the write would let a + // new worker claim the lock and then be marked failed while running. + lock, ok := s.claimDeadWorkerLock(state.ID) + if !ok { + return state + } + defer func() { _ = lock.Unlock() }() + + if s.afterClaimForTest != nil { + s.afterClaimForTest() + } + + return s.failAbandoned(state) +} + +// List returns every known task, most recently started first. +func (s *Store) List() ([]*State, error) { + entries, err := os.ReadDir(s.dir) + if err != nil { + return nil, fmt.Errorf("read task dir: %w", err) + } + + states := make([]*State, 0, len(entries)) + for _, entry := range entries { + if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" { + continue + } + id := entry.Name()[:len(entry.Name())-len(".json")] + state, err := s.Get(id) + if err != nil { + continue + } + states = append(states, state) + } + + sort.Slice(states, func(i, j int) bool { + return states[i].StartedAt.After(states[j].StartedAt) + }) + return states, nil +} + +// failAbandoned records ErrAbandoned for a task whose worker is confirmed gone. +// Must be called with the worker lock held. +func (s *Store) failAbandoned(state *State) *State { + current, err := s.Get(state.ID) + if err != nil { + return state + } + if current.Status.Terminal() { + return current + } + if err := s.Open(state.ID).Fail(ErrAbandoned); err != nil { + return current + } + reconciled, err := s.Get(state.ID) + if err != nil { + return current + } + return reconciled +} + +// claimDeadWorkerLock acquires the task's worker lock, which only succeeds when +// no worker holds it. +func (s *Store) claimDeadWorkerLock(id string) (*flock.Flock, bool) { + path, err := s.workerLockPath(id) + if err != nil { + return nil, false + } + // No lock file means the worker never got far enough to claim one. + if _, statErr := os.Stat(path); statErr != nil { + return nil, false + } + + lock := flock.New(path) + locked, err := lock.TryLock() + if err != nil || !locked { + return nil, false + } + return lock, true +} + +// workerLockPath is distinct from the read-modify-write lock in withLock: this +// one is held for the worker's entire lifetime, so sharing it would block +// every state update. +func (s *Store) workerLockPath(id string) (string, error) { + path, err := s.path(id) + if err != nil { + return "", err + } + return path + ".worker.lock", nil +} + +func (s *Store) update(id string, mutate func(*State)) error { + return s.withLock(id, func() error { + state, err := s.Get(id) + if err != nil { + return err + } + mutate(state) + state.touch() + return s.write(state) + }) +} + +// withLock runs fn holding an OS file lock scoped to id, so a concurrent +// read-modify-write from another process (or another Store instance in this +// one) can't interleave with it. +func (s *Store) withLock(id string, fn func() error) error { + path, err := s.path(id) + if err != nil { + return err + } + + lock := flock.New(path + ".lock") + ctx, cancel := context.WithTimeout(context.Background(), lockTimeout) + defer cancel() + locked, err := lock.TryLockContext(ctx, 20*time.Millisecond) + if err != nil { + return fmt.Errorf("lock task %s: %w", id, err) + } + if !locked { + return fmt.Errorf("lock task %s: timed out", id) + } + defer func() { _ = lock.Unlock() }() + + return fn() +} + +// path rejects any id that isn't a single clean path component, so a +// caller-supplied id like "../../etc/passwd" can't escape s.dir. +func (s *Store) path(id string) (string, error) { + if id == "" || id == "." || id == ".." || filepath.Base(id) != id { + return "", fmt.Errorf("invalid task id %q", id) + } + return filepath.Join(s.dir, id+".json"), nil +} + +// write atomically replaces the task's state file so a concurrent Get never +// observes a partially written file. +func (s *Store) write(state *State) error { + data, err := marshalState(state) + if err != nil { + return err + } + + target, err := s.path(state.ID) + if err != nil { + return err + } + tmp, err := os.CreateTemp(s.dir, state.ID+".*.tmp") + if err != nil { + return fmt.Errorf("create temp task file: %w", err) + } + tmpPath := tmp.Name() + defer func() { _ = os.Remove(tmpPath) }() + + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return fmt.Errorf("write task state: %w", err) + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return fmt.Errorf("sync temp task file: %w", err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("close temp task file: %w", err) + } + if err := os.Rename(tmpPath, target); err != nil { + return fmt.Errorf("commit task state: %w", err) + } + syncDir(s.dir) + return nil +} + +// syncDir fsyncs a directory so a rename survives a crash. +func syncDir(dir string) { + d, err := os.Open(dir) // #nosec G304 -- store's own task dir. + if err != nil { + return + } + defer func() { _ = d.Close() }() + _ = d.Sync() +} diff --git a/pkg/task/task.go b/pkg/task/task.go new file mode 100644 index 000000000..0282456a9 --- /dev/null +++ b/pkg/task/task.go @@ -0,0 +1,217 @@ +// Package task tracks detached background work so its status can +// be polled independently of the CLI invocation that started it. +package task + +import ( + "encoding/json" + "errors" + "fmt" + "strconv" + "time" + + "github.com/devsy-org/devsy/pkg/command" + "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/devsy-org/devsy/pkg/status" + "github.com/gofrs/flock" +) + +var ( + ErrCanceled = errors.New("canceled") + ErrAbandoned = errors.New("worker exited without recording a result") +) + +type Status string + +const ( + StatusPending Status = "pending" + StatusRunning Status = "running" + StatusSucceeded Status = "succeeded" + StatusFailed Status = "failed" +) + +func (s Status) Terminal() bool { + return s == StatusSucceeded || s == StatusFailed +} + +// State is the JSON snapshot persisted for a task. PID names the OS process +// doing the work, distinct from whatever process is merely polling this +// state. +type State struct { + ID string `json:"id"` + Command string `json:"command,omitempty"` + WorkspaceID string `json:"workspaceId,omitempty"` + Status Status `json:"status"` + Phase string `json:"phase,omitempty"` + Step string `json:"step,omitempty"` + Error string `json:"error,omitempty"` + Result *config.Result `json:"result,omitempty"` + PID int `json:"pid,omitempty"` + StartedAt time.Time `json:"startedAt"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// CreateOptions labels a task at creation time for later listing. +type CreateOptions struct { + Command string + WorkspaceID string +} + +// Task is a handle to a single background task, bound to the Store it was +// created in. Report may be called from multiple goroutines; each call +// serializes its own read-modify-write of the state file. +type Task struct { + store *Store + id string + // Held for the worker process's lifetime; see HoldWorkerLock. + workerLock *flock.Flock +} + +func (t *Task) ID() string { return t.id } + +func (t *Task) SetPID(pid int) error { + return t.store.update(t.id, func(s *State) { + s.PID = pid + }) +} + +// HoldWorkerLock claims this task's worker lock for the rest of the process's +// life, marking it as actively being worked on. Callers must not release it: +// the kernel does so when the process exits, crashes, or is killed. +// +// Returns an error if another process already holds the lock, since that means +// a worker for this task is already running. +func (t *Task) HoldWorkerLock() error { + path, err := t.store.workerLockPath(t.id) + if err != nil { + return err + } + + lock := flock.New(path) + locked, err := lock.TryLock() + if err != nil { + return fmt.Errorf("lock task %s worker: %w", t.id, err) + } + if !locked { + return fmt.Errorf("task %s already has a running worker", t.id) + } + // Deliberately retained, never unlocked: the handle must outlive this call + // so the lock is held for the process's lifetime. + t.workerLock = lock + return nil +} + +// ReleaseWorkerLockForTest drops the lock HoldWorkerLock acquired, letting a +// test simulate a dead worker. Production code must never call it. +// +// Not in export_test.go: other packages' tests need it, and a _test.go file is +// only compiled into its own package's test binary. +func (t *Task) ReleaseWorkerLockForTest() error { + if t.workerLock == nil { + return nil + } + lock := t.workerLock + t.workerLock = nil + if err := lock.Unlock(); err != nil { + return fmt.Errorf("unlock task %s worker: %w", t.id, err) + } + return nil +} + +// SetWorkspaceID corrects the task's workspace label to the resolved ID, +// which may differ from whatever label it was created with (e.g. a raw +// source string guessed before workspace resolution ran). client.Status +// looks tasks up by this label, so it must end up accurate. +func (t *Task) SetWorkspaceID(id string) error { + return t.store.update(t.id, func(s *State) { + s.WorkspaceID = id + }) +} + +func (t *Task) Reporter() status.Reporter { + return taskReporter{task: t} +} + +// Succeed is a no-op once the task is terminal, so a worker that finishes +// concurrently with a Cancel can't overwrite the canceled state with success. +func (t *Task) Succeed(result *config.Result) error { + return t.store.update(t.id, func(s *State) { + if s.Status.Terminal() { + return + } + s.Status = StatusSucceeded + s.Result = result + s.Error = "" + }) +} + +// Cancel is safe to call even if the process already exited on its own. The +// terminal check and state transition happen atomically under the same +// lock, so a concurrent report from the task's own worker can't race with +// marking it canceled here. +func (t *Task) Cancel() error { + var pid int + err := t.store.update(t.id, func(s *State) { + if s.Status.Terminal() { + return + } + pid = s.PID + s.Status = StatusFailed + s.Error = ErrCanceled.Error() + }) + if err != nil { + return err + } + if pid == 0 { + return nil + } + return command.Kill(strconv.Itoa(pid)) +} + +// Fail preserves an existing terminal state, so the error a canceled worker +// reports on its way out doesn't mask ErrCanceled as the reason it stopped. +func (t *Task) Fail(err error) error { + return t.store.update(t.id, func(s *State) { + if s.Status.Terminal() { + return + } + s.Status = StatusFailed + if err != nil { + s.Error = err.Error() + } + }) +} + +type taskReporter struct { + task *Task +} + +func (r taskReporter) Report(e status.Event) { + _ = r.task.store.update(r.task.id, func(s *State) { + // A terminal task is done being described; late events from a worker + // still unwinding must not resurrect it or rewrite its outcome. + if s.Status.Terminal() { + return + } + if e.Phase == status.PhaseFailed { + s.Error = e.Err + return + } + if s.Status == StatusPending { + s.Status = StatusRunning + } + s.Phase = string(e.Phase) + s.Step = e.Step + }) +} + +func (s *State) touch() { + s.UpdatedAt = time.Now() +} + +func marshalState(s *State) ([]byte, error) { + data, err := json.MarshalIndent(s, "", " ") + if err != nil { + return nil, fmt.Errorf("marshal task state: %w", err) + } + return data, nil +} diff --git a/pkg/task/task_test.go b/pkg/task/task_test.go new file mode 100644 index 000000000..a4cb74362 --- /dev/null +++ b/pkg/task/task_test.go @@ -0,0 +1,653 @@ +package task + +import ( + "errors" + "fmt" + "sync" + "testing" + "time" + + "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/devsy-org/devsy/pkg/status" +) + +func newTestStore(t *testing.T) *Store { + t.Helper() + store, err := NewStoreAt(t.TempDir()) + if err != nil { + t.Fatalf("NewStoreAt: %v", err) + } + return store +} + +func TestGetRejectsPathTraversal(t *testing.T) { + store := newTestStore(t) + for _, id := range []string{"../escape", "a/../../b", "/etc/passwd", ".", ".."} { + if _, err := store.Get(id); err == nil { + t.Errorf("Get(%q) = nil error, want rejection", id) + } + } +} + +func TestDeleteRejectsPathTraversal(t *testing.T) { + store := newTestStore(t) + if err := store.Delete("../escape", true); err == nil { + t.Error("Delete with traversal id = nil error, want rejection") + } +} + +func TestCreateStartsPending(t *testing.T) { + store := newTestStore(t) + task, err := store.Create(CreateOptions{}) + if err != nil { + t.Fatalf("Create: %v", err) + } + + state, err := store.Get(task.ID()) + if err != nil { + t.Fatalf("Get: %v", err) + } + if state.Status != StatusPending { + t.Errorf("status = %q, want %q", state.Status, StatusPending) + } +} + +func TestReporterTransitionsToRunning(t *testing.T) { + store := newTestStore(t) + task, err := store.Create(CreateOptions{}) + if err != nil { + t.Fatalf("Create: %v", err) + } + + reporter := task.Reporter() + status.Enter(reporter, status.PhaseBuildingImage, "") + + state, err := store.Get(task.ID()) + if err != nil { + t.Fatalf("Get: %v", err) + } + if state.Status != StatusRunning { + t.Errorf("status = %q, want %q", state.Status, StatusRunning) + } + if state.Phase != string(status.PhaseBuildingImage) { + t.Errorf("phase = %q, want %q", state.Phase, status.PhaseBuildingImage) + } +} + +func TestReporterRecordsFailure(t *testing.T) { + store := newTestStore(t) + task, err := store.Create(CreateOptions{}) + if err != nil { + t.Fatalf("Create: %v", err) + } + + status.Fail(task.Reporter(), status.PhaseRunningLifecycleHook, errors.New("boom")) + + state, err := store.Get(task.ID()) + if err != nil { + t.Fatalf("Get: %v", err) + } + if state.Error != "boom" { + t.Errorf("error = %q, want %q", state.Error, "boom") + } +} + +func TestSucceedRecordsResult(t *testing.T) { + store := newTestStore(t) + task, err := store.Create(CreateOptions{}) + if err != nil { + t.Fatalf("Create: %v", err) + } + + result := &config.Result{RecoveryContainer: true} + if err := task.Succeed(result); err != nil { + t.Fatalf("Succeed: %v", err) + } + + state, err := store.Get(task.ID()) + if err != nil { + t.Fatalf("Get: %v", err) + } + if state.Status != StatusSucceeded { + t.Errorf("status = %q, want %q", state.Status, StatusSucceeded) + } + if state.Result == nil || !state.Result.RecoveryContainer { + t.Errorf("result = %+v, want RecoveryContainer=true", state.Result) + } + if !state.Status.Terminal() { + t.Error("expected Succeeded to be terminal") + } +} + +func TestFailRecordsError(t *testing.T) { + store := newTestStore(t) + task, err := store.Create(CreateOptions{}) + if err != nil { + t.Fatalf("Create: %v", err) + } + + if err := task.Fail(errors.New("container build failed")); err != nil { + t.Fatalf("Fail: %v", err) + } + + state, err := store.Get(task.ID()) + if err != nil { + t.Fatalf("Get: %v", err) + } + if state.Status != StatusFailed { + t.Errorf("status = %q, want %q", state.Status, StatusFailed) + } + if state.Error != "container build failed" { + t.Errorf("error = %q", state.Error) + } +} + +func TestListOrdersMostRecentFirst(t *testing.T) { + store := newTestStore(t) + first, err := store.Create(CreateOptions{}) + if err != nil { + t.Fatalf("Create: %v", err) + } + // Force distinct StartedAt values without relying on real wall-clock gaps. + if err := store.update(first.ID(), func(s *State) { + s.StartedAt = s.StartedAt.Add(-time.Hour) + }); err != nil { + t.Fatalf("backdate first: %v", err) + } + second, err := store.Create(CreateOptions{}) + if err != nil { + t.Fatalf("Create: %v", err) + } + + states, err := store.List() + if err != nil { + t.Fatalf("List: %v", err) + } + if len(states) != 2 { + t.Fatalf("expected 2 tasks, got %d", len(states)) + } + if states[0].ID != second.ID() || states[1].ID != first.ID() { + t.Errorf("unexpected order: %+v", states) + } +} + +func TestSetPIDPersists(t *testing.T) { + store := newTestStore(t) + task, err := store.Create(CreateOptions{}) + if err != nil { + t.Fatalf("Create: %v", err) + } + + if err := task.SetPID(4242); err != nil { + t.Fatalf("SetPID: %v", err) + } + + state, err := store.Get(task.ID()) + if err != nil { + t.Fatalf("Get: %v", err) + } + if state.PID != 4242 { + t.Errorf("PID = %d, want 4242", state.PID) + } +} + +func TestCancelWithoutPIDMarksFailed(t *testing.T) { + store := newTestStore(t) + task, err := store.Create(CreateOptions{}) + if err != nil { + t.Fatalf("Create: %v", err) + } + + if err := task.Cancel(); err != nil { + t.Fatalf("Cancel: %v", err) + } + + state, err := store.Get(task.ID()) + if err != nil { + t.Fatalf("Get: %v", err) + } + if state.Status != StatusFailed { + t.Errorf("status = %q, want %q", state.Status, StatusFailed) + } + if state.Error != ErrCanceled.Error() { + t.Errorf("error = %q, want %q", state.Error, ErrCanceled.Error()) + } +} + +func TestCancelOnTerminalTaskIsNoop(t *testing.T) { + store := newTestStore(t) + task, err := store.Create(CreateOptions{}) + if err != nil { + t.Fatalf("Create: %v", err) + } + if err := task.Succeed(&config.Result{}); err != nil { + t.Fatalf("Succeed: %v", err) + } + + if err := task.Cancel(); err != nil { + t.Fatalf("Cancel: %v", err) + } + + state, err := store.Get(task.ID()) + if err != nil { + t.Fatalf("Get: %v", err) + } + if state.Status != StatusSucceeded { + t.Errorf("status = %q, want unchanged %q", state.Status, StatusSucceeded) + } +} + +// A canceled task's outcome must survive its worker finishing afterwards: +// the worker doesn't observe the cancel and reports its own result on the way +// out, which would otherwise erase the reason the task actually stopped. +func TestTerminalStateSurvivesLateWorkerReports(t *testing.T) { + for _, tc := range []struct { + name string + late func(*Task) error + }{ + {"succeed", func(tk *Task) error { return tk.Succeed(&config.Result{}) }}, + {"fail", func(tk *Task) error { return tk.Fail(errors.New("worker exited")) }}, + {"report", func(tk *Task) error { + tk.Reporter().Report(status.Event{Phase: status.PhaseReady, Started: true}) + return nil + }}, + } { + t.Run(tc.name, func(t *testing.T) { + store := newTestStore(t) + tsk, err := store.Create(CreateOptions{}) + if err != nil { + t.Fatalf("Create: %v", err) + } + if err := tsk.Cancel(); err != nil { + t.Fatalf("Cancel: %v", err) + } + + if err := tc.late(tsk); err != nil { + t.Fatalf("late report: %v", err) + } + + state, err := store.Get(tsk.ID()) + if err != nil { + t.Fatalf("Get: %v", err) + } + if state.Status != StatusFailed { + t.Errorf("status = %q, want %q", state.Status, StatusFailed) + } + if state.Error != ErrCanceled.Error() { + t.Errorf("error = %q, want %q", state.Error, ErrCanceled.Error()) + } + }) + } +} + +func TestCreateStoresLabels(t *testing.T) { + store := newTestStore(t) + tsk, err := store.Create(CreateOptions{Command: "up", WorkspaceID: "my-ws"}) + if err != nil { + t.Fatalf("Create: %v", err) + } + + state, err := store.Get(tsk.ID()) + if err != nil { + t.Fatalf("Get: %v", err) + } + if state.Command != "up" || state.WorkspaceID != "my-ws" { + t.Errorf("unexpected labels: %+v", state) + } +} + +func TestDeleteRequiresTerminalUnlessForced(t *testing.T) { + store := newTestStore(t) + tsk, err := store.Create(CreateOptions{}) + if err != nil { + t.Fatalf("Create: %v", err) + } + + if err := store.Delete(tsk.ID(), false); err == nil { + t.Error("expected Delete to fail on a non-terminal task") + } + if err := store.Delete(tsk.ID(), true); err != nil { + t.Fatalf("force Delete: %v", err) + } + if _, err := store.Get(tsk.ID()); err == nil { + t.Error("expected Get to fail after Delete") + } +} + +func TestDeleteTerminalTask(t *testing.T) { + store := newTestStore(t) + tsk, err := store.Create(CreateOptions{}) + if err != nil { + t.Fatalf("Create: %v", err) + } + if err := tsk.Succeed(&config.Result{}); err != nil { + t.Fatalf("Succeed: %v", err) + } + + if err := store.Delete(tsk.ID(), false); err != nil { + t.Fatalf("Delete: %v", err) + } + if _, err := store.Get(tsk.ID()); err == nil { + t.Error("expected Get to fail after Delete") + } +} + +// TestConcurrentUpdatesAcrossStoreInstancesAreSerialized simulates two +// separate processes (each with its own Store instance, as they would be in +// practice) racing to update the same task: a worker reporting progress and +// a canceller. Every individual update must be applied atomically — no +// update should be silently lost or the file left corrupt. +func TestConcurrentUpdatesAcrossStoreInstancesAreSerialized(t *testing.T) { + dir := t.TempDir() + workerStore, err := NewStoreAt(dir) + if err != nil { + t.Fatalf("NewStoreAt: %v", err) + } + tsk, err := workerStore.Create(CreateOptions{Command: "up"}) + if err != nil { + t.Fatalf("Create: %v", err) + } + + const n = 20 + var wg sync.WaitGroup + for i := range n { + wg.Go(func() { + store, err := NewStoreAt(dir) + if err != nil { + t.Errorf("NewStoreAt: %v", err) + return + } + if err := store.update(tsk.ID(), func(s *State) { + s.Step = fmt.Sprintf("step-%d", i) + }); err != nil { + t.Errorf("update: %v", err) + } + }) + } + wg.Wait() + + final, err := workerStore.Get(tsk.ID()) + if err != nil { + t.Fatalf("Get after concurrent updates: %v", err) + } + if final.Step == "" { + t.Error("expected some update's Step to have won, got empty") + } +} + +func TestConcurrentReportsAreRaceSafe(t *testing.T) { + store := newTestStore(t) + task, err := store.Create(CreateOptions{}) + if err != nil { + t.Fatalf("Create: %v", err) + } + + reporter := task.Reporter() + var wg sync.WaitGroup + for range 20 { + wg.Go(func() { + status.Enter(reporter, status.PhaseBuildingImage, "") + status.Leave(reporter, status.PhaseBuildingImage, "") + }) + } + wg.Wait() + + if _, err := store.Get(task.ID()); err != nil { + t.Fatalf("Get after concurrent reports: %v", err) + } +} + +// holdWorkerLock claims the task's worker lock and releases it on cleanup, so +// the fd closes even if an assertion fails (an open file can block TempDir +// removal on some platforms). +func holdWorkerLock(t *testing.T, tk *Task) { + t.Helper() + if err := tk.HoldWorkerLock(); err != nil { + t.Fatalf("HoldWorkerLock: %v", err) + } + t.Cleanup(func() { + if err := tk.ReleaseWorkerLockForTest(); err != nil { + t.Errorf("ReleaseWorkerLockForTest: %v", err) + } + }) +} + +func TestAbandonedIgnoresTaskWithNoWorkerLock(t *testing.T) { + // No lock file yet: the worker hasn't claimed the task, not abandoned. + store := newTestStore(t) + tk, err := store.Create(CreateOptions{Command: "up"}) + if err != nil { + t.Fatalf("Create: %v", err) + } + state, err := store.Get(tk.ID()) + if err != nil { + t.Fatalf("Get: %v", err) + } + + if store.Abandoned(state) { + t.Error("Abandoned(no worker lock) = true, want false") + } +} + +func TestAbandonedIgnoresTerminalTasks(t *testing.T) { + store := newTestStore(t) + for _, st := range []Status{StatusSucceeded, StatusFailed} { + if store.Abandoned(&State{ID: "any", Status: st}) { + t.Errorf("Abandoned(%s) = true, want false", st) + } + } +} + +func TestAbandonedTreatsHeldLockAsLive(t *testing.T) { + // The lock a live worker holds is not acquirable, so the task reads as live. + store := newTestStore(t) + tk, err := store.Create(CreateOptions{Command: "up"}) + if err != nil { + t.Fatalf("Create: %v", err) + } + holdWorkerLock(t, tk) + state, err := store.Get(tk.ID()) + if err != nil { + t.Fatalf("Get: %v", err) + } + + if store.Abandoned(state) { + t.Error("Abandoned(held lock) = true, want false") + } +} + +func TestAbandonedDetectsReleasedLock(t *testing.T) { + // Releasing stands in for the worker dying: the kernel drops the lock the + // same way, leaving it acquirable while the task is still non-terminal. + store := newTestStore(t) + tk, err := store.Create(CreateOptions{Command: "up"}) + if err != nil { + t.Fatalf("Create: %v", err) + } + if err := tk.HoldWorkerLock(); err != nil { + t.Fatalf("HoldWorkerLock: %v", err) + } + if err := tk.ReleaseWorkerLockForTest(); err != nil { + t.Fatalf("ReleaseWorkerLockForTest: %v", err) + } + state, err := store.Get(tk.ID()) + if err != nil { + t.Fatalf("Get: %v", err) + } + + if !store.Abandoned(state) { + t.Error("Abandoned(released lock) = false, want true") + } +} + +func TestHoldWorkerLockRejectsSecondWorker(t *testing.T) { + store := newTestStore(t) + tk, err := store.Create(CreateOptions{Command: "up"}) + if err != nil { + t.Fatalf("Create: %v", err) + } + holdWorkerLock(t, tk) + + // A second worker for the same task must not start alongside the first. + if err := store.Open(tk.ID()).HoldWorkerLock(); err == nil { + t.Error("second HoldWorkerLock = nil error, want rejection") + } +} + +// abandonTask creates a task, claims its worker lock, then releases it: the +// kernel frees a dead worker's lock the same way. +func abandonTask(t *testing.T, store *Store) *State { + t.Helper() + tk, err := store.Create(CreateOptions{Command: "up"}) + if err != nil { + t.Fatalf("Create: %v", err) + } + if err := tk.HoldWorkerLock(); err != nil { + t.Fatalf("HoldWorkerLock: %v", err) + } + if err := tk.ReleaseWorkerLockForTest(); err != nil { + t.Fatalf("ReleaseWorkerLockForTest: %v", err) + } + state, err := store.Get(tk.ID()) + if err != nil { + t.Fatalf("Get: %v", err) + } + return state +} + +func TestReconcileFailsAbandonedTask(t *testing.T) { + store := newTestStore(t) + state := abandonTask(t, store) + + reconciled := store.Reconcile(state) + + if reconciled.Status != StatusFailed { + t.Errorf("status = %s, want %s", reconciled.Status, StatusFailed) + } + if reconciled.Error != ErrAbandoned.Error() { + t.Errorf("error = %q, want %q", reconciled.Error, ErrAbandoned.Error()) + } +} + +func TestReconcilePersistsTheFailure(t *testing.T) { + store := newTestStore(t) + state := abandonTask(t, store) + + store.Reconcile(state) + + persisted, err := store.Get(state.ID) + if err != nil { + t.Fatalf("Get after reconcile: %v", err) + } + if persisted.Status != StatusFailed { + t.Errorf("persisted status = %s, want %s", persisted.Status, StatusFailed) + } +} + +func TestReconcileDoesNotFailARestartedWorker(t *testing.T) { + store := newTestStore(t) + tk, err := store.Create(CreateOptions{Command: "up"}) + if err != nil { + t.Fatalf("Create: %v", err) + } + if err := tk.HoldWorkerLock(); err != nil { + t.Fatalf("HoldWorkerLock: %v", err) + } + if err := tk.ReleaseWorkerLockForTest(); err != nil { + t.Fatalf("ReleaseWorkerLockForTest: %v", err) + } + stale, err := store.Get(tk.ID()) + if err != nil { + t.Fatalf("Get: %v", err) + } + + // A replacement worker takes over mid-reconcile. Holding the lock is what + // a real worker does; it must not be able to succeed here, because + // Reconcile still holds it. + restarted := store.Open(tk.ID()) + claimed := false + store.SetAfterClaimForTest(func() { + claimed = restarted.HoldWorkerLock() == nil + }) + + reconciled := store.Reconcile(stale) + + if claimed { + t.Error("a replacement worker acquired the lock during Reconcile") + if err := restarted.ReleaseWorkerLockForTest(); err != nil { + t.Errorf("ReleaseWorkerLockForTest: %v", err) + } + } + // With the lock held throughout, the reconcile is the only writer and the + // abandoned task is correctly failed. + if reconciled.Status != StatusFailed { + t.Errorf("status = %s, want %s", reconciled.Status, StatusFailed) + } +} + +func TestReconcileKeepsAWorkerRecordedResult(t *testing.T) { + store := newTestStore(t) + tk, err := store.Create(CreateOptions{Command: "up"}) + if err != nil { + t.Fatalf("Create: %v", err) + } + if err := tk.HoldWorkerLock(); err != nil { + t.Fatalf("HoldWorkerLock: %v", err) + } + stale, err := store.Get(tk.ID()) + if err != nil { + t.Fatalf("Get: %v", err) + } + // Worker succeeds, then exits (dropping its lock). + if err := tk.Succeed(nil); err != nil { + t.Fatalf("Succeed: %v", err) + } + if err := tk.ReleaseWorkerLockForTest(); err != nil { + t.Fatalf("ReleaseWorkerLockForTest: %v", err) + } + + reconciled := store.Reconcile(stale) + + if reconciled.Status != StatusSucceeded { + t.Errorf("status = %s, want %s", reconciled.Status, StatusSucceeded) + } + if reconciled.Error != "" { + t.Errorf("error = %q, want empty", reconciled.Error) + } +} + +func TestReconcileLeavesLiveTaskAlone(t *testing.T) { + store := newTestStore(t) + task, err := store.Create(CreateOptions{Command: "up"}) + if err != nil { + t.Fatalf("Create: %v", err) + } + holdWorkerLock(t, task) + state, err := store.Get(task.ID()) + if err != nil { + t.Fatalf("Get: %v", err) + } + + if got := store.Reconcile(state).Status; got != StatusPending { + t.Errorf("status = %s, want %s (unchanged)", got, StatusPending) + } +} + +func TestReconcilePreservesCancellationReason(t *testing.T) { + store := newTestStore(t) + task, err := store.Create(CreateOptions{Command: "up"}) + if err != nil { + t.Fatalf("Create: %v", err) + } + if err := task.Fail(ErrCanceled); err != nil { + t.Fatalf("Fail: %v", err) + } + state, err := store.Get(task.ID()) + if err != nil { + t.Fatalf("Get: %v", err) + } + + if got := store.Reconcile(state).Error; got != ErrCanceled.Error() { + t.Errorf("error = %q, want %q", got, ErrCanceled.Error()) + } +}