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..e6077d2e3 100644 --- a/cmd/internal/container_tunnel.go +++ b/cmd/internal/container_tunnel.go @@ -14,6 +14,7 @@ import ( pkgconfig "github.com/devsy-org/devsy/pkg/config" "github.com/devsy-org/devsy/pkg/devcontainer" "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/devsy-org/devsy/pkg/devcontainer/status" "github.com/devsy-org/devsy/pkg/encoding" cliflags "github.com/devsy-org/devsy/pkg/flags" "github.com/devsy-org/devsy/pkg/flags/names" @@ -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..185af2a64 --- /dev/null +++ b/cmd/workspace/task.go @@ -0,0 +1,354 @@ +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" + "github.com/devsy-org/devsy/pkg/devcontainer/status" + 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/task" + "github.com/spf13/cobra" +) + +// NewTaskCmd builds the 'devsy 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 +} + +// --- list --- + +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 +} + +// --- get --- + +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) +} + +// --- logs --- + +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) + } + 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: + } + } +} + +// emitTaskTransition prints a line only when phase/step changed since the +// last poll. +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) +} + +// --- cancel --- + +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 rather than routing through reportTaskState, + // which would turn state.Error (the task is now Failed) into this + // command's exit code — canceling successfully isn't itself a failure. + if emitJSON { + return json.NewEncoder(os.Stdout).Encode(state) + } + _, _ = fmt.Fprintf(os.Stdout, "task %s: canceled\n", state.ID) + return nil +} + +// --- rm --- + +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) +} + +// --- shared --- + +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..801424d59 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(), }) } @@ -230,6 +232,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..3692892e1 --- /dev/null +++ b/cmd/workspace/up/detach.go @@ -0,0 +1,129 @@ +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 instead of waiting for the workspace to be ready. The task is +// a re-exec of this same command with --detach swapped for --task-id, so it +// runs the same pipeline an attached `up` would. +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 `workspace 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 wasn't launched via --detach. +// Records this process's PID so a caller can later cancel the actual work. +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) + 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..a6b16494d --- /dev/null +++ b/cmd/workspace/up/status.go @@ -0,0 +1,58 @@ +package up + +import ( + "io" + + config2 "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/devsy-org/devsy/pkg/devcontainer/status" + "github.com/devsy-org/devsy/pkg/log" +) + +// newStatusReporter drives `up`'s progress output: one NDJSON status line +// per phase transition in JSON mode, human-readable info lines otherwise. +func newStatusReporter(emitJSON bool, out io.Writer) status.Reporter { + if emitJSON { + return &jsonStatusReporter{out: out} + } + return plainStatusReporter{} +} + +type jsonStatusReporter struct { + out io.Writer +} + +func (r *jsonStatusReporter) Report(e status.Event) { + _ = 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)) + } +} + +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..352115d78 100644 --- a/cmd/workspace/up/up.go +++ b/cmd/workspace/up/up.go @@ -13,6 +13,7 @@ import ( client2 "github.com/devsy-org/devsy/pkg/client" "github.com/devsy-org/devsy/pkg/config" config2 "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/devsy-org/devsy/pkg/devcontainer/status" "github.com/devsy-org/devsy/pkg/flags/names" "github.com/devsy-org/devsy/pkg/ide" "github.com/devsy-org/devsy/pkg/ide/opener" @@ -54,8 +55,16 @@ 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 (--task-id). + 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 +224,53 @@ func (cmd *UpCmd) Run( emitJSON := mode == output.ModeJSON out := cmd.stdout() + cmd.statusReporter = newStatusReporter(emitJSON, out) + + t, err := cmd.openTask() + if err != nil { + return err + } + if t != nil { + cmd.statusReporter = status.Tee(cmd.statusReporter, t.Reporter()) + // The submitting process's guessed label may not match the + // resolved ID; client.Status() looks tasks up by this label. + if err := t.SetWorkspaceID(client.Workspace()); err != nil { + failTask(t, err) + return err + } + } + 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 +391,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..c5b02a9b8 100755 --- a/desktop/e2e/fixtures/mock-devsy.cjs +++ b/desktop/e2e/fixtures/mock-devsy.cjs @@ -75,6 +75,8 @@ function defaultState() { context: "default", }, ], + // Background tasks submitted via `workspace up --detach`, keyed by id. + tasks: {}, } } @@ -92,6 +94,8 @@ function saveState(state) { } const state = loadState() +// Older persisted state files predate the task store. +state.tasks = state.tasks || {} const rawArgs = process.argv.slice(2) @@ -190,17 +194,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, mirroring what a real `up` produces. +function materializeWorkspace(wsId, source, providerFlag, ideFlag) { state.workspaces.push({ id: wsId, uid: `ws-${Date.now()}`, @@ -213,9 +208,127 @@ 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" + + // `--detach` submits a background task instead of running to completion; + // the caller polls it via `workspace task logs --follow`. + 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, matching pkg/devcontainer/config/envelope.go. + 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 +380,7 @@ const workspaceHandlers = { status: handleStatus, ssh: handleSsh, up: handleUp, + task: handleTask, stop: handleStop, delete: handleDelete, rename: handleRename, diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index 127105aa0..d028f0c2f 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,6 +166,10 @@ export function registerIpcHandlers(deps: IpcDependencies): { string, import("node:child_process").ChildProcess >() + // Maps workspaceId -> task id for still-running `up --detach` submissions. + // Stopping the tunnelProcesses entry (a poller) doesn't stop provisioning; + // only cancelling the task by id does. + const activeUpTasks = new Map() /** * Terminate every desktop-spawned process tied to a workspace and wait for @@ -173,6 +178,14 @@ export function registerIpcHandlers(deps: IpcDependencies): { * workspace's log directory. */ async function quiesceWorkspace(workspaceId: string): Promise { + const taskId = activeUpTasks.get(workspaceId) + if (taskId) { + activeUpTasks.delete(workspaceId) + await cli + .run(["workspace", "task", "cancel", taskId]) + .catch(() => undefined) + } + const tunnelProc = tunnelProcesses.get(workspaceId) if (tunnelProc) { tunnelProcesses.delete(workspaceId) @@ -737,32 +750,85 @@ 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 + // Kill any existing tunnel/poller process and cancel any still-running + // task for this workspace before starting a new one. const existing = tunnelProcesses.get(wsId) if (existing) { existing.kill("SIGTERM") tunnelProcesses.delete(wsId) } + const existingTask = activeUpTasks.get(wsId) + if (existingTask) { + activeUpTasks.delete(wsId) + await cli + .run(["workspace", "task", "cancel", existingTask]) + .catch(() => undefined) + } + + // Submit: returns almost immediately with the background task's id. + let taskId: string + try { + const submitted = await cli.run<{ kind: string; id: string }>([ + ...cliArgs, + "--detach", + ]) + 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) + let signalledDone = false const child = await cli.runStreaming( - cliArgs, - (line) => { + ["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 (line.includes('"outcome":"success"')) { + if (envelope?.kind === "result") { signalledDone = true - // Track this as a tunnel process (it stays alive for the tunnel) - tunnelProcesses.set(wsId, child) + activeUpTasks.delete(wsId) void sink.done(formatted) return } + if (envelope?.kind === "error") { + signalledDone = true + activeUpTasks.delete(wsId) + void sink.done(formatted, { + level: "error", + cliError: { code: "up_failed", message: envelope.message }, + }) + return + } + if (!sink.line(formatted)) return logStore.onDrain(logPath) }, (code, cliError) => { + activeUpTasks.delete(wsId) if (tunnelProcesses.get(wsId) === child) { tunnelProcesses.delete(wsId) } @@ -774,6 +840,9 @@ export function registerIpcHandlers(deps: IpcDependencies): { }, wsId, ) + // Tracked so quiesceWorkspace can also stop the poller; the task + // cancellation above is what actually stops the provisioning itself. + tunnelProcesses.set(wsId, child) 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..d8008d5c4 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 up` 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..b78af6033 100644 --- a/desktop/src/shared/cli-error.ts +++ b/desktop/src/shared/cli-error.ts @@ -10,3 +10,60 @@ export interface CliLogLine { cliError?: CLIError [key: string]: unknown } + +/** + * NDJSON envelope shapes written by `workspace up --result-format json`. + * Mirrors pkg/devcontainer/config/envelope.go — keep in sync. + */ +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/docs/rfcs/async-workspace-up.md b/docs/rfcs/async-workspace-up.md new file mode 100644 index 000000000..b47be9730 --- /dev/null +++ b/docs/rfcs/async-workspace-up.md @@ -0,0 +1,441 @@ +# RFC: Asynchronous `workspace up` and structured status + +## Status + +Draft. No backward compatibility is preserved by design — breaking changes to +the CLI JSON output contract, the `Runner` interface, the tunnel proto, and +the desktop IPC contract are in scope. + +## Problem + +`workspace up` is a single synchronous call chain from CLI flag parsing all +the way to IDE launch. Every layer blocks on the one below it and returns a +single terminal value: + +``` +cmd/workspace/up.Run + -> devsyUp -> dispatchClient (blocks on client.Up / tunnel RunUpServer) + -> devcontainer.Runner.Up(ctx, options, timeout) (*config.Result, error) + -> runSingleContainer / runDockerCompose + -> build (docker/buildkit exec, blocking) + -> driver.Run (blocking) + -> setupContainer -> inner tunnel -> RunPreAttachHooks (blocking up to waitFor) + -> finalizeUp (SSH config, tunnel, IDE open) +``` + +The only two structured signals that cross a process boundary are: + +- `tunnel.LogMessage` — a level (`DEBUG/INFO/DONE/WARNING/ERROR`) + freeform + string, sent continuously. +- `tunnel.SendResult` — the final `config.Result`, sent exactly once, at the + end. + +There is no phase, step, or percentage concept anywhere. The desktop app +compensates by spawning the CLI as a subprocess, parsing every stdout line as +a zap JSON log record, and detecting completion by +`line.includes('"outcome":"success"')` (`desktop/src/main/ipc.ts:755`) — a +string sniff on log text, not a contract. + +Consequences: + +- The CLI cannot report progress faster than log lines happen to convey it. +- The desktop app cannot show "building image" vs "running postCreate" vs + "waiting for port" as distinct states — only "still running" or "done". +- Nothing downstream of `up` (IDE open, SSH tunnel) can start until the + entire blocking chain returns, even when only the pieces it actually + depends on (e.g. container is running, `waitFor` phase reached) are ready. + +## Goals + +1. Every meaningful step of `up` (resolve config, initializeCommand, build + image, start container, inject agent, run each lifecycle hook, waitFor, + ready) emits a structured, typed status event as it starts/finishes — + not just a log line. +2. The event stream crosses every process boundary that today only carries + `LogMessage`/`Result` (the outer CLI<->agent tunnel and the inner + container-setup tunnel). +3. The CLI's `--result-format json` mode emits status as NDJSON as it + happens, not only a single envelope at the end. +4. The desktop app consumes the structured stream directly — no string + sniffing on log content for control flow. +5. Steps that don't depend on each other can run concurrently where the + underlying operation allows it (e.g. pulling/building images for + docker-compose services, or preparing SSH config while lifecycle hooks + past `waitFor` are still running in the background). +6. `up` can return control to its caller as soon as the work is submitted, + without waiting for the workspace to be ready — the caller (CLI user or + desktop UI) decides whether to wait or poll. This is the actual meaning of + "asynchronous" this RFC targets: not reordering the devcontainer-spec's + inherently sequential build/inject/hook chain (that chain has real data + dependencies and reordering it would be incorrect), but decoupling + *submission* from *completion* the way the platform/daemon path already + decouples them for remote workspaces. + +## Non-goals + +- Changing the devcontainer.json spec or lifecycle hook semantics themselves. +- A general pub/sub system for arbitrary future features — scope is the + `up`/`build`/`setup` path. +- Preserving the current single-shot JSON result envelope shape unchanged; + it is superseded by the event stream plus a final terminal event. + +## Design + +### 1. Status/phase model (`pkg/devcontainer/status`) + +A closed set of phases mirroring the real dependency chain of `up`: + +```go +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" // + HookName field + PhaseWaitingFor Phase = "waiting_for" + PhaseReady Phase = "ready" + PhaseFailed Phase = "failed" +) + +type Event struct { + Phase Phase + Step string // e.g. hook name, service name; empty when not applicable + Started bool // true = entering phase, false = phase complete + Err string // set only when Phase == PhaseFailed +} + +type Reporter interface { + Report(Event) +} +``` + +A `NopReporter` and a `ChannelReporter` (buffered channel + drain goroutine, +same shape as the existing `tunnelLogger` pattern) are the two initial +implementations. + +### 2. `Runner` interface takes a `Reporter` + +```go +type Runner interface { + Up(ctx context.Context, options UpOptions, timeout time.Duration, r status.Reporter) (*config.Result, error) + // ... unchanged otherwise +} +``` + +Breaking change: every existing caller of `Up` must pass a reporter +(`status.Nop()` is fine where nobody listens yet). `run.go`, `single.go`, +`compose_up.go`, `build.go`, `setup.go`, `lifecyclehooks.go` each call +`r.Report(...)` at the start/end of their step instead of (or in addition to) +`log.Info`. + +### 3. Tunnel proto: `StatusUpdate` RPC + +```protobuf +message StatusUpdate { + string phase = 1; + string step = 2; + bool started = 3; + string error = 4; +} + +service Tunnel { + ... + rpc Status(StatusUpdate) returns (Empty) {} +} +``` + +Both tunnels that exist today (`RunUpServer` for the outer CLI<->agent +session, `RunSetupServer` for the inner container-setup session) forward +`status.Reporter` events over this RPC the same way `tunnelLogger` forwards +`LogMessage` today (buffered chan + worker goroutine, `pkg/agent/tunnelserver/logger.go` +is the template). The host-side tunnel client fans inbound `StatusUpdate` +messages into a `status.Reporter` the CLI process owns. + +### 4. CLI: NDJSON status stream + +`cmd/workspace/up` gets a `status.Reporter` that, in JSON result-format mode, +writes one compact JSON object per event to stdout immediately (not +buffered until exit), followed by the existing terminal `ResultEnvelope`/ +`ErrorEnvelope` as the final line. Plain-text mode renders the same events +as human-readable progress lines (this can replace a good number of today's +ad hoc `log.Info` progress messages in the up path). + +This is a breaking change to the CLI's JSON output contract: consumers that +assumed exactly one JSON object on stdout must now expect a stream and +identify the terminal one (e.g. by a `"kind":"result"` discriminator field +added to `ResultEnvelope`/`ErrorEnvelope`, vs `"kind":"status"` on events). + +### 5. Desktop: structured consumption + +`desktop/src/main/ipc.ts`'s `workspace_up` handler stops treating stdout as +opaque log text for control flow. It parses each NDJSON line, and: + +- `"kind":"status"` lines update a per-workspace phase state machine and are + forwarded to the renderer as a new `workspace-status` IPC event (structured + `{ commandId, phase, step, started }`), replacing the current + `command-progress` line-batching for control-flow purposes (raw log text + can still be forwarded for the log viewer, but is no longer load-bearing). +- `"kind":"result"` lines replace the `line.includes('"outcome":"success"')` + sniff. + +`desktop/src/shared/cli-error.ts` (or a new `cli-status.ts`) gains the shared +TypeScript types for `StatusEvent`/`ResultEnvelope` mirroring the Go JSON +shapes, generated or hand-kept in sync (no codegen exists today; hand-kept +with a comment pointing at the Go source of truth is acceptable for now). + +### 6. Two execution flows, and standard verbs for the durable one + +This is the piece that actually decouples "start provisioning" from "wait +for it to finish," per Goal 6. + +**Synchronous** (default, unchanged): `workspace up ` attaches and +waits, exactly as before — no reason to force a UX change on the common +interactive case. + +**Durable**: `workspace up --detach`/`-d` submits the same pipeline +as a background task and returns immediately, printing +`{"kind":"task","id":"..."}`. "Durable" here means the task's state +survives past the submitting process's exit — it's a file, not something +held in memory by a process someone has to keep watching. + +**`pkg/task`** is that file's home: one JSON file per task under +`PathManager.TaskDir()` (`/tasks/.json`), written atomically +(temp file + rename). `State` carries `Status` (`pending` / `running` / +`succeeded` / `failed`), the most recent `Phase`/`Step`, labels +(`Command`, `WorkspaceID`) for listing, a `PID` for cancellation, and — once +terminal — the final `Error` or `*config.Result`. `Task.Reporter()` returns +a `status.Reporter` that persists each event into the file. + +**`cmd/workspace/up/detach.go`** implements submission: create a task, then +re-exec the current binary with this invocation's original arguments — +`--detach` swapped for the hidden `--task-id ` — as a background process +(`pkg/command.StartBackground`, the same primitive already used for deferred +lifecycle hooks). The parent does no devcontainer work itself. **The +re-exec'd child** runs through the exact same `execute` → `Run` → +`executeDevsyUp` → `finalizeUp` path an attached `up` always has — nothing +about the devcontainer-spec-ordered pipeline changes; `Run` just tees its +`status.Reporter` (`status.Tee`) into both the normal NDJSON/log output *and* +the task file, and calls `Succeed`/`Fail` on the task at the same points it +would otherwise just return. The child *is* today's `up`, observed from two +places instead of one — which is why devcontainer-spec ordering needed no +verification beyond "did we change anything in `pkg/devcontainer`?" (no). + +**The task resource gets standard verbs** (`cmd/workspace/task.go`), rather +than one command overloaded with `--wait`/`--cancel` flags, so it reads like +every other resource-oriented CLI (kubectl, docker, gh): + +| Command | Aliases | Verb category | +|---|---|---| +| `workspace task list` | `ls` | List | +| `workspace task get ` | `describe`, `show` | Get/Show/Describe | +| `workspace task logs [-f\|--follow]` | `attach` | Logs/Tail (docker/kubectl `-f`) | +| `workspace task cancel ` | `stop` | Stop/Cancel | +| `workspace task rm [--force]` | `delete`, `remove` | Delete/Rm/Remove | + +`get`/`logs` without `-f` are the same operation (one snapshot); `logs -f` +polls on an interval (`--interval`, default 500ms), printing the phase +observed at each poll when it differs from the last, then the final +result/error envelope (exit code reflects success/failure). `State` stores +only the current phase/step, not a history — this is a latest-state +snapshot, not a replay log, so a burst of transitions between two polls can +be collapsed to just the last one observed. `cancel` sends the signal +recorded via `Task.SetPID` (set by the detached child on itself) and marks +the task failed with `task.ErrCanceled`; canceling successfully is reported +as success even though the task's own terminal state is "failed." `rm` +refuses to delete a non-terminal task unless `--force`, which cancels it +first (stopping the worker) before deleting — mirroring `kubectl delete +--force`/`docker rm -f`, both of which also stop the resource, not just +remove its record. + +This gives every caller — interactive CLI, script, or desktop — the choice +DevContainer-remote already had for platform workspaces: submit and don't +wait, or submit and wait, with status observable in both cases, using verbs +that don't need this doc to explain them. + +**Desktop** (`desktop/src/main/ipc.ts`): `workspace_up` now submits via +`up --detach` (`cli.run`, one-shot), then streams `workspace task logs +--follow` instead of streaming `up` directly — the NDJSON envelope parsing +is unchanged, since `logs` emits the same envelope shapes `up` does. A new +`activeUpTasks: Map` tracks the task backing the real +provisioning process; `quiesceWorkspace` (run before stop/delete) now issues +`workspace task cancel ` against it before touching any child process, +because the child it *can* see (the `logs --follow` poller) is not the +process doing the work — killing only the poller would silently leave +provisioning running. This also means the desktop app can now query a +task's status even if Electron itself was restarted while `up` was running, +which it could not do before (progress was tied to the lifetime of the +child process it spawned). + +### Concurrency + +Structured status is a prerequisite for observing overlap, not a substitute +for it — the point of this RFC is that steps of `up` which don't depend on +each other actually run at the same time. Implemented so far: + +- **Feature resolution** (`pkg/devcontainer/feature.getUserFeatures`): each + user-configured feature is fetched (OCI pull, tarball download, or local + read) concurrently via `errgroup`, instead of one at a time. `lockfileState` + gained a mutex around its `entries` map since `record` is now called from + multiple goroutines. Race-tested with `go test -race`. +- **Agent binary prefetch** (`runner.prefetchAgentBinary`, called from + `runSingleContainer`): the agent binary download/cache-read starts on a + background goroutine at the same time as container resolution + (`resolveContainer`, which builds and starts the container), instead of + starting only once the container is already running and injection is + ready. It's best-effort and silently discards its result on failure — the + real acquisition on the injection path re-runs from scratch and simply + hits the now-warm on-disk cache in the common case. +- **`daemonclient` (platform/pro) `Up` path** + (`pkg/client/clientimplementation/daemonclient/up.go`): `UpOptions` gained + a `Reporter status.Reporter` field, threaded through + `waitTaskDone`/`observeTask`/`printLogs`. The remote task the daemon + observes runs the same `devsy` CLI, so its forwarded log stream already + contains the same `kind":"status"` NDJSON lines a local `up` emits; a new + `statusSniffingWriter` splits that stream into lines, forwards recognized + status lines to the `Reporter` (via the new `config.ParseStatusLine` + helper), and passes every other line through to the existing zap-log + renderer unchanged. This does not make the call return before the remote + task finishes — that's a correctness requirement, not a gap: `Up` must + return the final `*config.Result`, which only exists once the remote task + completes — but it does mean this path now participates in the same + structured-status model as the local path instead of being informationally + disconnected from it (previously: opaque zap JSON only, `status.Reporter` + events did not exist for this path at all). Covered by + `statusSniffingWriter`'s dedicated unit tests (race-tested). + +Already-async before this RFC, noted for completeness: `DeferredHooks` (the +lifecycle hooks that run after `waitFor`) are launched as a detached +background OS process (`cmd/internal/agentcontainer/setup.go:startDeferredHooks`) +rather than awaited inline — `up` does not block on them today. Likewise, +docker-compose service builds are not a sequential loop in this codebase to +begin with: `runComposeBuild` shells out once to `docker compose ... build` +(`compose_build.go:runComposeBuild`), and the Compose CLI itself parallelizes +independent service builds — there's no Go-level per-service loop here to +convert. + +Remaining candidate, not yet converted: + +- Per-lifecycle-hook granularity: the inner container-setup tunnel currently + reports one `PhaseRunningLifecycleHook` span for the whole pre-`waitFor` + hook sequence rather than one per hook. + +This RFC does not mandate converting every step in one pass — each +conversion above was verified independently (build + `go test -race`) rather +than landed as one big-bang rewrite. + +## Breaking changes summary + +| Area | Before | After | +|---|---|---| +| `devcontainer.Runner.Up` | `(ctx, options, timeout) (*Result, error)` | `(ctx, options, timeout, status.Reporter) (*Result, error)` | +| Tunnel proto | `Log` + `SendResult` only | adds `Status` RPC | +| CLI JSON stdout | one `ResultEnvelope`/`ErrorEnvelope` object | NDJSON stream of status events + one terminal result/error object, each tagged with a `kind` discriminator | +| Desktop completion detection | `stdout.includes('"outcome":"success"')` | parse `"kind":"result"` NDJSON line | +| Desktop progress IPC | `command-progress` (raw log line batches) | new `workspace-status` (structured phase) + `command-progress` (raw log, display-only) | +| `client.UpOptions` | no status visibility | gains `Reporter status.Reporter` | +| `workspace up` | always attaches and waits | `--detach`/`-d` submits a `pkg/task` and returns immediately; default behavior unchanged | +| Local task observability | none — progress only exists on the invoking process's stdout | `workspace task {list,get,logs}` reads `pkg/task`'s on-disk state, independent of any live process | +| Canceling an in-progress `up` | kill the CLI process desktop happened to spawn | `workspace task cancel ` (PID-tracked, works regardless of who's watching) | +| Desktop `workspace_up` IPC | spawns `up` directly, streams its output | spawns `up --detach` (one-shot), then streams `workspace task logs --follow`; `quiesceWorkspace` cancels the task, not just its poller | +| `client.Status` | `Running`/`Busy`/`Stopped`/`NotFound` | adds `Provisioning` and `Failed`: `workspaceClient.Status()` looks at the most recently started `up` task on record for the workspace and reports one of them instead of `NotFound` when it's more informative — `Provisioning` while in flight, `Failed` if it errored — so `workspace status` doesn't claim a workspace was never started, or silently forget a failed attempt | +| `git clone` progress | raw `--progress` output logged as-is (unreadable: `\r`-delimited updates collapse into one giant line once captured as log lines) | new `status.PhaseCloningRepository`: `pkg/git`'s `progressWriter` parses the `\r`-delimited updates and reports them as `status.Event`s (thinned to every 10%) instead of logging them; plain, JSON, and desktop consumption all get clone progress through the same pipeline as every other phase, for free | +| `log.Writer` (subprocess Stdout/Stderr, ~65 call sites) | raw byte passthrough straight to the stderr sink, bypassing the configured encoder — every JSON-mode log call except this one produced `{"level":...}`, this one leaked unencoded text into the stream | line-buffers and calls `Info`/`Debug`/`Error` per complete line, so subprocess output goes through the same json/text/logfmt encoder as everything else; contradicted its own doc comment ("writes each line as a log entry") before this fix | + +## Rollout + +1. Land `pkg/devcontainer/status` (event model + Nop/Channel reporters) with + no behavior change — nothing calls `Report` with anything meaningful yet. +2. Thread `status.Reporter` through `Runner.Up` and its call chain, emitting + real events at each existing step boundary (no new proto yet — reporter is + in-process only for the direct/non-tunneled drivers first). +3. Add the `StatusUpdate` tunnel RPC and wire both tunnel sessions to forward + events across the process boundary. +4. Switch CLI JSON output to NDJSON + discriminator, update `pkg/output`. +5. Update desktop `ipc.ts`/`cli.ts`/shared types to consume structured events; + remove the string-sniff. +6. Opportunistically parallelize the concurrency candidates listed above, + one at a time, each covered by its own e2e test under `e2e/tests/up*`. +7. Land `pkg/task` and `workspace up --detach`, reusing the reporter + plumbing from steps 1-4 unchanged — the detached child is the same + attached-mode code path, just with its reporter teed into a task file. + No changes to `pkg/devcontainer` were needed for this step, which is + itself evidence the devcontainer-spec ordering wasn't touched. +8. Wire the desktop app onto the submit/attach model: + `desktop/src/main/ipc.ts`'s `workspace_up` handler now submits via + `up --detach` (`cli.run`, one-shot — returns as soon as the task exists), + then streams the task's status instead of streaming `up` directly. A new + `activeUpTasks: Map` tracks the task backing the real + (detached) provisioning process; `quiesceWorkspace` (called before + stop/delete) now cancels it before touching any live child process, + because the child it *can* see (the poller) is no longer the process + actually doing the work — killing only the poller would silently leave + provisioning running. This needed `pkg/task` to gain PID tracking + (`Task.SetPID`, set by the detached child on itself as soon as it opens + its task) and `Task.Cancel` (`pkg/command.Kill` on that PID, then + `Fail(ErrCanceled)`) — the "some other commands will be impacted" part + of the original ask materializing. +9. Replace the ad hoc `workspace task [--wait] [--cancel]` command with + standard verbs on the task resource — `list`/`get`/`logs [-f]`/`cancel`/ + `rm [--force]` — matching kubectl/docker/gh conventions instead of one + command overloaded with flags. `pkg/task` gained `CreateOptions` + (`Command`/`WorkspaceID` labels, so `list` is useful) and `Store.Delete` + for `rm`. Desktop's two call sites (poll, cancel) were updated to the new + verbs in the same change — see the table above. +10. Close the loop between `pkg/task` and container-level status: + `workspaceClient.Status()` (`pkg/client/clientimplementation`) now + reports `client.StatusProvisioning` or `client.StatusFailed` in place of + `StatusNotFound` based on the most recently started `up` task on record + for the workspace (`workspaceClient.taskStatusOverride()` lists + `pkg/task`, matches on `WorkspaceID`+`Command`, and picks the task with + the latest `StartedAt`) — `Provisioning` while it's non-terminal, + `Failed` if it ended in error, and no override at all if it succeeded + (a successful task implies nothing; if the container's still missing + after that, something else removed it, and `NotFound` is the honest + answer). The detached child corrects its task's `WorkspaceID` label to + the client's resolved ID once known (`Task.SetWorkspaceID`, called from + `Run`) rather than trusting the submitting process's guess (raw + `--id`/source string), since the lookup depends on that label being + accurate. +11. Route git clone progress through the same structured pipeline instead of + dropping it. First pass tried reformatting git's raw `--progress` text + for readability (a client-side writer splitting on `\r`); simpler and + more correct was noticing that no code anywhere parses that text for a + UI, so there was nothing to preserve about the raw format at all — the + real fix was giving clone progress the same treatment every other phase + already gets. `status` gains `PhaseCloningRepository`; `pkg/git` gains + `WithProgressReporter` (a `RepoOption`) and a `progressWriter` that + parses `\r`-delimited "label: NN% (x/y)" updates and reports them as + `status.Event`s (thinned to every 10%, exact-duplicate frames dropped) + instead of writing them to the log — informational lines like "Cloning + into '...'" still log normally. The reporter is threaded from the + agent's tunnel client (`tunnelserver.NewTunnelStatusReporter`, already + built in step 3) through `prepareWorkspace`/`prepareGitWorkspace` into + `agent.CloneWorkspaceParams`, so it crosses the same tunnel boundary as + every other phase and reaches the CLI's NDJSON/plain output and + desktop's `workspace-status` IPC event for free — no new plumbing + needed on either of those ends. + +Each phase should be independently mergeable and leaves the system in a +working (if not yet fully async) state — the plan is incremental even though +no compatibility shims are kept between phases. + +12. Harden `pkg/task` against the two ways it's actually multi-writer: a + detached worker reporting progress and a separate `task cancel`/`rm + --force` process both mutate the same state file, and `Get`/`Delete` + take a caller-supplied ID from argv. `Store.update` now holds an OS file + lock (`github.com/gofrs/flock`, one `.lock` file per task, 5s timeout) + around each read-modify-write, replacing a process-local `sync.Mutex` + that only ever protected against races within one process. `Cancel` + reads the terminal check and applies the state transition inside that + same locked update instead of as a separate Get-then-Fail, so a + concurrent worker report can't land between them. `Store.path` rejects + any ID that isn't a single clean path component, closing a path + traversal a raw `../../etc/passwd`-style ID could otherwise reach. + `pkg/command.kill` (Unix) also stopped discarding `syscall.Kill`'s + error — it now returns real failures and only treats "process already + gone" (`ESRCH`) as success, so `Cancel` can't silently claim a kill + succeeded when it didn't. diff --git a/pkg/agent/tunnel/tunnel.pb.go b/pkg/agent/tunnel/tunnel.pb.go index 55197c060..92a13a112 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,76 @@ func (x *LogMessage) GetMessage() string { return "" } +// StatusUpdate carries a structured up-pipeline phase transition, distinct +// from the freeform LogMessage stream. See docs/rfcs/async-workspace-up.md. +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 +604,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 +616,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 +629,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 +704,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 +717,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 +773,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..ce0ef5755 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,15 @@ message LogMessage { string message = 2; } +// StatusUpdate carries a structured up-pipeline phase transition, distinct +// from the freeform LogMessage stream. See docs/rfcs/async-workspace-up.md. +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..8e1ce8760 100644 --- a/pkg/agent/tunnelserver/options.go +++ b/pkg/agent/tunnelserver/options.go @@ -6,6 +6,7 @@ import ( "github.com/devsy-org/api/pkg/devsy" "github.com/devsy-org/devsy/pkg/agent/tunnel" "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/devsy-org/devsy/pkg/devcontainer/status" "github.com/devsy-org/devsy/pkg/netstat" provider2 "github.com/devsy-org/devsy/pkg/provider" ) @@ -75,6 +76,14 @@ func WithGitToken(token *provider2.GitToken) Option { } } +// WithStatusReporter forwards inbound StatusUpdate RPCs to reporter. +func WithStatusReporter(reporter status.Reporter) Option { + return func(s *tunnelServer) *tunnelServer { + 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..d50b1a3c2 --- /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/devcontainer/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..2fc9ac7cd 100644 --- a/pkg/agent/tunnelserver/tunnelserver.go +++ b/pkg/agent/tunnelserver/tunnelserver.go @@ -16,6 +16,7 @@ import ( "github.com/devsy-org/devsy/pkg/agent/tunnel" pkgconfig "github.com/devsy-org/devsy/pkg/config" "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/devsy-org/devsy/pkg/devcontainer/status" "github.com/devsy-org/devsy/pkg/devsyconfig" "github.com/devsy-org/devsy/pkg/dockercredentials" "github.com/devsy-org/devsy/pkg/extract" @@ -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..aafaa7e9a 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -8,6 +8,7 @@ import ( "github.com/devsy-org/api/pkg/devsy" "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/devsy-org/devsy/pkg/devcontainer/status" "github.com/devsy-org/devsy/pkg/provider" "golang.org/x/crypto/ssh" ) @@ -166,6 +167,10 @@ type UpOptions struct { Stdin io.Reader Stdout io.Writer + + // Reporter receives up-pipeline phase events when the implementation + // can observe them. May be nil. + Reporter status.Reporter } type SshOptions struct { @@ -182,6 +187,10 @@ const ( StatusBusy = "Busy" StatusStopped = "Stopped" StatusNotFound = "NotFound" + // No container yet, but a pkg/task `up --detach` task is building one. + StatusProvisioning = "Provisioning" + // No container, and the most recent `up --detach` task errored. + StatusFailed = "Failed" ) func ParseStatus(in string) (Status, error) { @@ -195,11 +204,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..c656184e7 100644 --- a/pkg/client/clientimplementation/daemonclient/stop.go +++ b/pkg/client/clientimplementation/daemonclient/stop.go @@ -7,6 +7,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/devcontainer/status" "github.com/devsy-org/devsy/pkg/platform" 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..d26f90360 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" @@ -17,6 +18,7 @@ import ( clientpkg "github.com/devsy-org/devsy/pkg/client" devsyconfig "github.com/devsy-org/devsy/pkg/config" "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/devsy-org/devsy/pkg/devcontainer/status" "github.com/devsy-org/devsy/pkg/log" "github.com/devsy-org/devsy/pkg/platform" platformclient "github.com/devsy-org/devsy/pkg/platform/client" @@ -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,49 @@ 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 +} + +func (w *statusSniffingWriter) Close() error { + if w.buf.Len() == 0 { + return nil + } + _, err := w.next.Write(w.buf.Bytes()) + w.buf.Reset() + 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..21b35cfe4 --- /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/devcontainer/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..dbd4ce344 100644 --- a/pkg/client/clientimplementation/workspace_client.go +++ b/pkg/client/clientimplementation/workspace_client.go @@ -19,11 +19,13 @@ import ( "github.com/devsy-org/devsy/pkg/compress" "github.com/devsy-org/devsy/pkg/config" config2 "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/devsy-org/devsy/pkg/devcontainer/status" "github.com/devsy-org/devsy/pkg/log" "github.com/devsy-org/devsy/pkg/options" "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/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,51 @@ 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, when more informative than +// a bare "no container". A succeeded task implies nothing: if the container +// is still missing after that, something else removed it. Best-effort: any +// error reading task state reports ok=false rather than failing Status. +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 + } + + var latest *task.State + for _, st := range states { + if st.WorkspaceID != s.workspace.ID || 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) } @@ -1100,7 +1158,8 @@ func runTunnelServer( opts.WorkspaceClient.AgentInjectDockerCredentials(opts.CLIOptions), opts.WorkspaceClient.WorkspaceConfig(), append(opts.TunnelOptions, - tunnelserver.WithGitToken(opts.CLIOptions.GitToken))..., + tunnelserver.WithGitToken(opts.CLIOptions.GitToken), + tunnelserver.WithStatusReporter(status.NewLogReporter()))..., ) 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..58bbfd6be --- /dev/null +++ b/pkg/client/clientimplementation/workspace_client_status_test.go @@ -0,0 +1,126 @@ +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"))) + + // Real wall-clock gap so the second task is unambiguously "most recent". + 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}} + // The second (later) task is still in flight, so it wins over the + // earlier failure. + 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 92668b0b1..58a96c1b8 100644 --- a/pkg/compose/helper_test.go +++ b/pkg/compose/helper_test.go @@ -2,6 +2,7 @@ package compose import ( "context" + "os/exec" "strings" "testing" @@ -183,6 +184,17 @@ func (r stubRuntime) GPUAvailable(_ context.Context, _ *docker.DockerHelper) (bo } func (s *HelperTestSuite) TestNewComposeHelperPodmanRuntimeUsesDockerCommand() { + // NewComposeHelper's podman detector shells out to a live `podman + // compose`, which needs a running podman machine/socket (e.g. on macOS, + // `podman machine start`) even when the podman binary is installed. CI + // runners (Linux) run podman natively and don't hit this; a local dev + // machine without a started VM would otherwise see this test silently + // fall through to a different compose backend and fail on the assertion + // below rather than skip. + if exec.Command("podman", "compose", "version").Run() != nil { + s.T().Skip("podman compose not reachable in test environment (is podman machine running?)") + } + helper := &docker.DockerHelper{ DockerCommand: "podman", Runtime: stubRuntime{name: docker.RuntimePodman}, diff --git a/pkg/config/pathmanager.go b/pkg/config/pathmanager.go index 1301bab4a..28be99348 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,6 +385,16 @@ func (b *basePathManager) LogDir() (string, error) { return filepath.Join(dir, "logs"), nil } +// TaskDir holds pkg/task's 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 +} + // --- Singleton management --- var ( diff --git a/pkg/devcontainer/build.go b/pkg/devcontainer/build.go index 76989151e..0b5ac36cf 100644 --- a/pkg/devcontainer/build.go +++ b/pkg/devcontainer/build.go @@ -14,6 +14,7 @@ import ( "github.com/devsy-org/devsy/pkg/devcontainer/config" "github.com/devsy-org/devsy/pkg/devcontainer/feature" "github.com/devsy-org/devsy/pkg/devcontainer/metadata" + "github.com/devsy-org/devsy/pkg/devcontainer/status" "github.com/devsy-org/devsy/pkg/dockerfile" "github.com/devsy-org/devsy/pkg/driver" "github.com/devsy-org/devsy/pkg/image" @@ -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..cce5dde6c 100644 --- a/pkg/devcontainer/config/envelope.go +++ b/pkg/devcontainer/config/envelope.go @@ -4,9 +4,23 @@ import ( "encoding/json" "fmt" "io" + "strings" + + "github.com/devsy-org/devsy/pkg/devcontainer/status" +) + +// Kind discriminates the NDJSON lines `up --result-format json` writes to +// stdout: a stream of "status" lines as phases complete, terminated by +// exactly one "result" or "error" line. +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 +31,44 @@ type ResultEnvelope struct { } type ErrorEnvelope struct { + Kind string `json:"kind"` Outcome string `json:"outcome"` Message string `json:"message"` } +// StatusEnvelope is one NDJSON line reporting a phase transition of the up +// pipeline as it happens, ahead of the terminal ResultEnvelope/ErrorEnvelope. +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: the ID of +// the background task it submitted, so the caller can poll it later (e.g. +// `workspace task `) instead of waiting for it to finish. +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 to w. The caller // supplies the envelope fields; this function stamps Outcome="success" and // appends a trailing newline. func WriteResultJSON(w io.Writer, env ResultEnvelope) error { + env.Kind = KindResult env.Outcome = "success" data, err := json.Marshal(env) if err != nil { @@ -36,6 +80,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 +91,40 @@ 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 (see +// WriteStatusJSON), returning ok=false for anything else — plain log text, +// a zap record, or a terminal result/error 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 to w. +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..69084dee5 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,44 @@ 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; featureProcessor and +// lockfileState are safe for concurrent use (see their doc comments). 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..e60d888ee 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" @@ -150,9 +151,13 @@ type lockfileMode struct { } // lockfileState carries the lockfile loaded for pinning and collects the -// entries resolved during a fetch so they can be written afterwards. +// entries resolved during a fetch so they can be written afterwards. record +// is called concurrently when features are fetched in parallel, so writes to +// entries are guarded by mu; loaded is populated once before any fetch starts +// and only read afterward, so it needs no locking. type lockfileState struct { loaded *Lockfile + mu sync.Mutex entries map[string]LockedFeature } @@ -169,12 +174,15 @@ func (l *lockfileState) pin(featureID string) (resolved, integrity string, ok bo return entry.Resolved, entry.Integrity, true } -// record stores the resolved entry for a feature identifier. +// record stores the resolved entry for a feature identifier. Safe to call +// concurrently from multiple feature fetches. 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..2b8ad4543 100644 --- a/pkg/devcontainer/run.go +++ b/pkg/devcontainer/run.go @@ -8,6 +8,7 @@ import ( "github.com/devsy-org/devsy/pkg/clierr" "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/devsy-org/devsy/pkg/devcontainer/status" "github.com/devsy-org/devsy/pkg/driver" "github.com/devsy-org/devsy/pkg/driver/drivercreate" "github.com/devsy-org/devsy/pkg/encoding" @@ -18,7 +19,13 @@ import ( // 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 reports phase transitions to reporter; pass status.Nop() to skip. + 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 +90,10 @@ type runner struct { idLabels []string recovering bool + + // Set at the start of Up; read by other runner methods via this field + // rather than threaded through every call site. + reporter status.Reporter } func NewRunner( @@ -110,6 +121,7 @@ func NewRunner( id: GetRunnerIDFromWorkspace(workspaceConfig.Workspace), idLabels: workspaceConfig.CLIOptions.IDLabels, workspaceConfig: workspaceConfig, + reporter: status.Nop(), }, nil } @@ -124,25 +136,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. 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 +181,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..1bfb1c3e8 100644 --- a/pkg/devcontainer/setup.go +++ b/pkg/devcontainer/setup.go @@ -20,6 +20,7 @@ import ( "github.com/devsy-org/devsy/pkg/devcontainer/config" "github.com/devsy-org/devsy/pkg/devcontainer/crane" "github.com/devsy-org/devsy/pkg/devcontainer/sshtunnel" + "github.com/devsy-org/devsy/pkg/devcontainer/status" "github.com/devsy-org/devsy/pkg/docker" "github.com/devsy-org/devsy/pkg/driver" "github.com/devsy-org/devsy/pkg/flags/names" @@ -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,26 @@ func (r *runner) deliverPostStart(ctx context.Context, strategy delivery.AgentDe return nil } +// prefetchAgentBinary warms the binary cache while the container builds. +// Best-effort: failures are ignored, and the real injection-path +// acquisition retries from scratch regardless. +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 +502,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..5974995c7 100644 --- a/pkg/devcontainer/single.go +++ b/pkg/devcontainer/single.go @@ -15,6 +15,7 @@ import ( "github.com/devsy-org/devsy/pkg/daemon/agent" "github.com/devsy-org/devsy/pkg/devcontainer/config" "github.com/devsy-org/devsy/pkg/devcontainer/metadata" + "github.com/devsy-org/devsy/pkg/devcontainer/status" "github.com/devsy-org/devsy/pkg/driver" "github.com/devsy-org/devsy/pkg/language" "github.com/devsy-org/devsy/pkg/log" @@ -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/devcontainer/status/log.go b/pkg/devcontainer/status/log.go new file mode 100644 index 000000000..841c9f023 --- /dev/null +++ b/pkg/devcontainer/status/log.go @@ -0,0 +1,23 @@ +package status + +import "github.com/devsy-org/devsy/pkg/log" + +// logReporter renders events as debug log lines. It is an interim consumer +// until the CLI/desktop stream structured events directly (see +// docs/rfcs/async-workspace-up.md) — real callers should prefer that once it +// lands, but this keeps events observable everywhere in the meantime. +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/devcontainer/status/status.go b/pkg/devcontainer/status/status.go new file mode 100644 index 000000000..5a120ae5d --- /dev/null +++ b/pkg/devcontainer/status/status.go @@ -0,0 +1,71 @@ +// Package status defines the structured progress events emitted during +// workspace up, in place of relying on freeform log text for control flow. +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. Started distinguishes entering a phase +// from completing it; Step carries phase-specific detail (e.g. the +// lifecycle hook name) and is empty when not applicable. Err is set only +// when Phase is PhaseFailed, and names the phase that failed via Step. +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 multiple 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, e.g. both the CLI's own NDJSON +// output and a persisted task state. +func Tee(reporters ...Reporter) Reporter { + return teeReporter(reporters) +} diff --git a/pkg/devcontainer/status/status_test.go b/pkg/devcontainer/status/status_test.go new file mode 100644 index 000000000..521f82a0a --- /dev/null +++ b/pkg/devcontainer/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/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/git/progress.go b/pkg/git/progress.go new file mode 100644 index 000000000..4d27643ef --- /dev/null +++ b/pkg/git/progress.go @@ -0,0 +1,82 @@ +package git + +import ( + "bytes" + "io" + "regexp" + "strconv" + + "github.com/devsy-org/devsy/pkg/log" +) + +// progressLine matches git's repeating "