diff --git a/cmd/internal/agentworkspace/up.go b/cmd/internal/agentworkspace/up.go index 8ce715a0d..306a69014 100644 --- a/cmd/internal/agentworkspace/up.go +++ b/cmd/internal/agentworkspace/up.go @@ -16,6 +16,7 @@ import ( "github.com/devsy-org/devsy/pkg/agent/tunnel" "github.com/devsy-org/devsy/pkg/agent/tunnelserver" "github.com/devsy-org/devsy/pkg/client/clientimplementation" + "github.com/devsy-org/devsy/pkg/clierr" "github.com/devsy-org/devsy/pkg/command" "github.com/devsy-org/devsy/pkg/config" "github.com/devsy-org/devsy/pkg/credentials" @@ -151,11 +152,10 @@ func (cmd *UpCmd) up( ) error { result, err := cmd.devsyUp(ctx, workspaceInfo) if err != nil { - // Forward the structured error back to the host through the tunnel - // BEFORE returning so the CLI can surface the actual cause (e.g. - // host requirements not met) instead of the generic "did not - // receive a result back from agent" fallback. - errResult := &config2.Result{Error: err.Error()} + errResult := &config2.Result{ + Error: err.Error(), + RecoveryAvailable: errors.Is(err, clierr.ErrBuildFailedRecoverable), + } if sendErr := cmd.sendResult(ctx, errResult, tunnelClient); sendErr != nil { log.Errorf("failed to forward up error %q to host: %v", err, sendErr) } diff --git a/cmd/root.go b/cmd/root.go index f20bae512..7a166abda 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -212,6 +212,9 @@ func exitCodeForError(err error, machineMode bool) int { if errors.Is(err, workspace.ErrWorkspaceNotFound) { return exitcode.Retryable } + if cliErr.Code == clierr.CodeBuildFailedRecoverable { + return exitcode.BuildFailedRecoverable + } return exitcode.Failure } diff --git a/cmd/workspace/status.go b/cmd/workspace/status.go index 56f4f152f..bdf107fe9 100644 --- a/cmd/workspace/status.go +++ b/cmd/workspace/status.go @@ -15,6 +15,7 @@ import ( 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/provider" workspace2 "github.com/devsy-org/devsy/pkg/workspace" "github.com/spf13/cobra" ) @@ -24,7 +25,8 @@ type StatusCmd struct { *flags.GlobalFlags client2.StatusOptions - Timeout string + Timeout string + Recovery bool } // NewStatusCmd creates a new command. @@ -55,6 +57,9 @@ func NewStatusCmd(globalFlags *flags.GlobalFlags) *cobra.Command { "If enabled shows the workspace container status as well"), cliflags.String(&cmd.Timeout, names.Timeout, "30s", "The timeout to wait until the status can be retrieved"), + cliflags.Bool(&cmd.Recovery, names.Recovery, false, + "Include whether the running container is a recovery container "+ + "(JSON output only)"), ) return statusCmd } @@ -90,12 +95,14 @@ func (cmd *StatusCmd) Run( case output.ModePlain: _, _ = fmt.Fprintln(os.Stdout, string(instanceStatus)) case output.ModeJSON: - out, err := json.Marshal(&client2.WorkspaceStatus{ + status := client2.WorkspaceStatus{ ID: client.Workspace(), Context: client.Context(), Provider: client.Provider(), State: string(instanceStatus), - }) + Recovery: cmd.resolveRecovery(client, instanceStatus), + } + out, err := json.Marshal(&status) if err != nil { return err } @@ -106,6 +113,23 @@ func (cmd *StatusCmd) Run( return nil } +// resolveRecovery reports whether the running container was built in recovery +// mode, looked up from the persisted workspace result. It is opt-in (--recovery) +// so the frequent status poll pays no extra disk read. +func (cmd *StatusCmd) resolveRecovery( + c client2.BaseWorkspaceClient, + status client2.Status, +) bool { + if !cmd.Recovery || status != client2.StatusRunning { + return false + } + result, err := provider.LoadWorkspaceResult(c.Context(), c.Workspace()) + if err != nil || result == nil { + return false + } + return result.RecoveryContainer +} + func (cmd *StatusCmd) execute(ctx context.Context, args []string) error { if _, err := clientimplementation.DecodeOptionsFromEnv( config.EnvFlagsStatus, &cmd.StatusOptions, diff --git a/cmd/workspace/up/up.go b/cmd/workspace/up/up.go index fcbf1e617..326ac992c 100644 --- a/cmd/workspace/up/up.go +++ b/cmd/workspace/up/up.go @@ -330,8 +330,10 @@ func reportErr(err error, emitJSON bool, out io.Writer) error { func emitUpResult(wctx *workspaceContext, ideURL string, out io.Writer) { containerID := config2.GetContainerID(wctx.result) var warnings []string + recovery := false if wctx.result != nil { warnings = wctx.result.HostWarnings + recovery = wctx.result.RecoveryContainer } _ = config2.WriteResultJSON(out, config2.ResultEnvelope{ ContainerID: containerID, @@ -339,6 +341,7 @@ func emitUpResult(wctx *workspaceContext, ideURL string, out io.Writer) { RemoteWorkspaceFolder: wctx.workdir, URL: ideURL, Warnings: warnings, + Recovery: recovery, }) } @@ -472,7 +475,7 @@ func (cmd *UpCmd) executeDevsyUp( func validateUpResult(result *config2.Result, err error) error { if resultErr := result.Err(); resultErr != nil { if err != nil { - return fmt.Errorf("start workspace: %s: %w", resultErr, err) + return fmt.Errorf("start workspace: %w: %w", resultErr, err) } return fmt.Errorf("start workspace: %w", resultErr) } diff --git a/cmd/workspace/up/up_flags.go b/cmd/workspace/up/up_flags.go index 82668a8a4..ea4131309 100644 --- a/cmd/workspace/up/up_flags.go +++ b/cmd/workspace/up/up_flags.go @@ -75,6 +75,9 @@ func (cmd *UpCmd) registerBuildFlags(upCmd *cobra.Command) { "instead of writing it"), flags.Bool(&cmd.NoLockfile, names.NoLockfile, false, "Disable devcontainer-lock.json generation and verification"), + flags.Bool(&cmd.Recovery, names.Recovery, false, + "If the dev container build fails, launch a recovery container with features and "+ + "lifecycle commands disabled so you can repair devcontainer.json and rebuild"), ) flags.RegisterDevContainerModifierFlags(upCmd.Flags(), flags.DevContainerModifierFlags{ Image: &cmd.DevContainerImage, diff --git a/desktop/src/main/ipc.ts b/desktop/src/main/ipc.ts index 092df77bc..127105aa0 100644 --- a/desktop/src/main/ipc.ts +++ b/desktop/src/main/ipc.ts @@ -240,8 +240,8 @@ export function registerIpcHandlers(deps: IpcDependencies): { ipcMain.handle( "workspace_status", - async (_event, args: { workspaceId: string }) => { - return cli.runRaw([ + async (_event, args: { workspaceId: string; recovery?: boolean }) => { + const cliArgs = [ "workspace", "status", args.workspaceId, @@ -249,7 +249,9 @@ export function registerIpcHandlers(deps: IpcDependencies): { "json", "--timeout", "5s", - ]) + ] + if (args.recovery) cliArgs.push("--recovery") + return cli.runRaw(cliArgs) }, ) @@ -704,6 +706,7 @@ export function registerIpcHandlers(deps: IpcDependencies): { devcontainer?: string prebuildRepository?: string platform?: string + recovery?: boolean }, ) => { trackEvent("workspace_create", { @@ -723,6 +726,7 @@ export function registerIpcHandlers(deps: IpcDependencies): { if (args.prebuildRepository) cliArgs.push("--prebuild-repo", args.prebuildRepository) if (args.platform) cliArgs.push("--platform", args.platform) + if (args.recovery) cliArgs.push("--recovery") const wsId = args.workspaceId ?? args.source const cmdId = crypto.randomUUID() @@ -758,13 +762,14 @@ export function registerIpcHandlers(deps: IpcDependencies): { if (!sink.line(formatted)) return logStore.onDrain(logPath) }, - (code) => { + (code, cliError) => { if (tunnelProcesses.get(wsId) === child) { tunnelProcesses.delete(wsId) } if (signalledDone) return void sink.done( formatLogLine(`Exit code: ${code}`, code === 0 ? "INFO" : "ERROR"), + code === 0 ? undefined : { level: "error", cliError }, ) }, wsId, @@ -884,9 +889,10 @@ export function registerIpcHandlers(deps: IpcDependencies): { (line) => { if (!sink.line(formatLogLine(line))) return logStore.onDrain(logPath) }, - (code) => { + (code, cliError) => { void sink.done( formatLogLine(`Exit code: ${code}`, code === 0 ? "INFO" : "ERROR"), + code === 0 ? undefined : { level: "error", cliError }, ) }, args.workspaceId, @@ -922,9 +928,10 @@ export function registerIpcHandlers(deps: IpcDependencies): { (line) => { if (!sink.line(formatLogLine(line))) return logStore.onDrain(logPath) }, - (code) => { + (code, cliError) => { void sink.done( formatLogLine(`Exit code: ${code}`, code === 0 ? "INFO" : "ERROR"), + code === 0 ? undefined : { level: "error", cliError }, ) }, args.workspaceId, diff --git a/desktop/src/renderer/src/lib/components/workspace/WorkspaceCard.svelte b/desktop/src/renderer/src/lib/components/workspace/WorkspaceCard.svelte index 603421a26..c6000684c 100644 --- a/desktop/src/renderer/src/lib/components/workspace/WorkspaceCard.svelte +++ b/desktop/src/renderer/src/lib/components/workspace/WorkspaceCard.svelte @@ -3,11 +3,7 @@ import { goto } from "$lib/router.js" import { Button } from "$lib/components/ui/button/index.js" import { badgeVariants } from "$lib/components/ui/badge/index.js" import ConfirmDialog from "$lib/components/layout/ConfirmDialog.svelte" -import { - workspaceUp, - workspaceStop, - workspaceDelete, -} from "$lib/ipc/commands.js" +import { workspaceStop, workspaceDelete } from "$lib/ipc/commands.js" import { toasts } from "$lib/stores/toasts.js" import { extractErrorMessage } from "$lib/utils/error.js" import type { Workspace } from "$lib/types/index.js" @@ -38,17 +34,9 @@ function handleOpen(e: Event) { goto(`/workspaces/${workspace.id}?action=open-ide`) } -async function handleStart(e: Event) { +function handleStart(e: Event) { e.stopPropagation() - acting = true - try { - await workspaceUp({ source: workspace.id }) - toasts.success(`Starting ${workspace.id}...`) - } catch (err) { - toasts.error(`Failed to start: ${extractErrorMessage(err)}`) - } finally { - acting = false - } + goto(`/workspaces/${workspace.id}?action=start`) } async function handleStop(e: Event) { @@ -127,9 +115,7 @@ async function handleDelete() { Open {:else if isStopped} - + {/if} {#if isRunning || isBusy} {:else if launchError} - + {#if launchBuildFailed && !launchIsRecovery} + + {/if} + {/if} diff --git a/desktop/src/renderer/src/lib/ipc/commands.test.ts b/desktop/src/renderer/src/lib/ipc/commands.test.ts index acdd39d59..a8b5e9aa1 100644 --- a/desktop/src/renderer/src/lib/ipc/commands.test.ts +++ b/desktop/src/renderer/src/lib/ipc/commands.test.ts @@ -68,6 +68,15 @@ describe("IPC commands", () => { }) }) + it("workspaceUp forwards recovery", async () => { + mockInvoke.mockResolvedValue("cmd-recovery") + await workspaceUp({ source: "my-repo", recovery: true }) + expect(mockInvoke).toHaveBeenCalledWith("workspace_up", { + source: "my-repo", + recovery: true, + }) + }) + it("workspaceStop passes workspaceId", async () => { await workspaceStop("ws-1") expect(mockInvoke).toHaveBeenCalledWith("workspace_stop", { @@ -94,10 +103,20 @@ describe("IPC commands", () => { const result = await workspaceStatus("ws-1") expect(mockInvoke).toHaveBeenCalledWith("workspace_status", { workspaceId: "ws-1", + recovery: false, }) expect(result).toBe('{"state":"Running"}') }) + it("workspaceStatus requests recovery when asked", async () => { + mockInvoke.mockResolvedValue('{"state":"Running","recovery":true}') + await workspaceStatus("ws-1", true) + expect(mockInvoke).toHaveBeenCalledWith("workspace_status", { + workspaceId: "ws-1", + recovery: true, + }) + }) + it("workspaceUp forwards devcontainer and prebuildRepository", async () => { mockInvoke.mockResolvedValue("cmd-id") await workspaceUp({ diff --git a/desktop/src/renderer/src/lib/ipc/commands.ts b/desktop/src/renderer/src/lib/ipc/commands.ts index 1fb491858..0c0ef7bbe 100644 --- a/desktop/src/renderer/src/lib/ipc/commands.ts +++ b/desktop/src/renderer/src/lib/ipc/commands.ts @@ -46,6 +46,7 @@ export async function workspaceUp(params: { devcontainer?: string prebuildRepository?: string platform?: string + recovery?: boolean }): Promise { return invoke("workspace_up", params) } @@ -78,8 +79,11 @@ export async function workspaceReset( return invoke("workspace_reset", { workspaceId, debug }) } -export async function workspaceStatus(workspaceId: string): Promise { - return invoke("workspace_status", { workspaceId }) +export async function workspaceStatus( + workspaceId: string, + recovery = false, +): Promise { + return invoke("workspace_status", { workspaceId, recovery }) } export async function workspaceRename( diff --git a/desktop/src/renderer/src/lib/stores/settings.test.ts b/desktop/src/renderer/src/lib/stores/settings.test.ts index 9e7d0a6d2..479838fb3 100644 --- a/desktop/src/renderer/src/lib/stores/settings.test.ts +++ b/desktop/src/renderer/src/lib/stores/settings.test.ts @@ -1,7 +1,13 @@ import { get } from "svelte/store" import { beforeEach, describe, expect, it } from "vitest" -import { applyTheme, cycleTheme, theme } from "./settings.js" +import { + applyTheme, + cycleTheme, + getWorkspaceRecoveryState, + setWorkspaceRecoveryState, + theme, +} from "./settings.js" describe("settings store", () => { beforeEach(() => { @@ -67,4 +73,37 @@ describe("settings store", () => { expect(get(theme)).toBe("light") }) }) + + describe("workspace recovery state", () => { + it("returns empty state when nothing is stored", () => { + expect(getWorkspaceRecoveryState("ws-1")).toEqual({}) + }) + + it("persists and reads back per-workspace state", () => { + setWorkspaceRecoveryState("ws-1", { + buildFailed: true, + inRecovery: false, + }) + expect(getWorkspaceRecoveryState("ws-1")).toEqual({ + buildFailed: true, + inRecovery: false, + }) + }) + + it("keeps state isolated per workspace", () => { + setWorkspaceRecoveryState("ws-1", { inRecovery: true }) + setWorkspaceRecoveryState("ws-2", { buildFailed: true }) + expect(getWorkspaceRecoveryState("ws-1")).toEqual({ inRecovery: true }) + expect(getWorkspaceRecoveryState("ws-2")).toEqual({ buildFailed: true }) + }) + + it("clears the entry when no flags are set", () => { + setWorkspaceRecoveryState("ws-1", { buildFailed: true }) + setWorkspaceRecoveryState("ws-1", { + buildFailed: false, + inRecovery: false, + }) + expect(getWorkspaceRecoveryState("ws-1")).toEqual({}) + }) + }) }) diff --git a/desktop/src/renderer/src/lib/stores/settings.ts b/desktop/src/renderer/src/lib/stores/settings.ts index f75d37c03..bce06b10d 100644 --- a/desktop/src/renderer/src/lib/stores/settings.ts +++ b/desktop/src/renderer/src/lib/stores/settings.ts @@ -213,7 +213,8 @@ export interface ContextOptions { sshConfigIncludePath: string } -// Options stored locally (not supported by Devsy CLI context) +export type OnBuildFailure = "prompt" | "auto-recovery" | "nothing" + export interface LocalOptions { debugFlag: boolean sshKeyPath: string @@ -223,6 +224,7 @@ export interface LocalOptions { additionalCliFlags: string additionalEnvVars: string experimentalMultiDevcontainer: boolean + onBuildFailure: OnBuildFailure } export const DEFAULT_CONTEXT_OPTIONS: ContextOptions = { @@ -254,6 +256,7 @@ export const DEFAULT_LOCAL_OPTIONS: LocalOptions = { additionalCliFlags: "", additionalEnvVars: "", experimentalMultiDevcontainer: false, + onBuildFailure: "prompt", } // Map from our keys to Devsy CLI context option keys @@ -417,3 +420,47 @@ export function setWorkspaceFolder( // ignore } } + +export interface WorkspaceRecoveryState { + buildFailed?: boolean + inRecovery?: boolean +} + +const WORKSPACE_RECOVERY_KEY = "devsy-workspace-recovery" + +export function getWorkspaceRecoveryState( + workspaceId: string, +): WorkspaceRecoveryState { + if (!browser) return {} + try { + const stored = localStorage.getItem(WORKSPACE_RECOVERY_KEY) + if (stored) { + const map = JSON.parse(stored) as Record + return map[workspaceId] ?? {} + } + } catch { + // ignore + } + return {} +} + +export function setWorkspaceRecoveryState( + workspaceId: string, + state: WorkspaceRecoveryState, +): void { + if (!browser) return + try { + const stored = localStorage.getItem(WORKSPACE_RECOVERY_KEY) + const map: Record = stored + ? JSON.parse(stored) + : {} + if (state.buildFailed || state.inRecovery) { + map[workspaceId] = state + } else { + delete map[workspaceId] + } + localStorage.setItem(WORKSPACE_RECOVERY_KEY, JSON.stringify(map)) + } catch { + // ignore + } +} diff --git a/desktop/src/renderer/src/lib/utils/log-parser.test.ts b/desktop/src/renderer/src/lib/utils/log-parser.test.ts new file mode 100644 index 000000000..3c3aaf095 --- /dev/null +++ b/desktop/src/renderer/src/lib/utils/log-parser.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest" + +import { + isRecoverableBuildFailure, + parseRecoveryContainer, +} from "./log-parser.js" + +describe("isRecoverableBuildFailure", () => { + it("matches the recoverable build-failure code", () => { + expect( + isRecoverableBuildFailure({ code: "BUILD_FAILED_RECOVERABLE" }), + ).toBe(true) + }) + + it("does not match other error codes (e.g. compose/unknown)", () => { + expect(isRecoverableBuildFailure({ code: "UNKNOWN" })).toBe(false) + }) + + it("returns false when there is no cliError", () => { + expect(isRecoverableBuildFailure()).toBe(false) + expect(isRecoverableBuildFailure(null)).toBe(false) + }) +}) + +describe("parseRecoveryContainer", () => { + it("reads recovery=true from a success envelope", () => { + expect( + parseRecoveryContainer('{"outcome":"success","recovery":true}'), + ).toBe(true) + }) + + it("returns false for a success envelope without recovery", () => { + expect(parseRecoveryContainer('{"outcome":"success"}')).toBe(false) + }) + + it("returns null for non-success or non-envelope messages", () => { + expect(parseRecoveryContainer("Exit code: 0")).toBe(null) + expect(parseRecoveryContainer('{"outcome":"error"}')).toBe(null) + expect(parseRecoveryContainer(null)).toBe(null) + }) +}) diff --git a/desktop/src/renderer/src/lib/utils/log-parser.ts b/desktop/src/renderer/src/lib/utils/log-parser.ts index 9349389c5..0275f1368 100644 --- a/desktop/src/renderer/src/lib/utils/log-parser.ts +++ b/desktop/src/renderer/src/lib/utils/log-parser.ts @@ -7,6 +7,14 @@ export function stripAnsi(str: string): string { return str.replace(ANSI_RE, "").replace(BRACKET_RE, "") } +const RECOVERABLE_BUILD_FAILURE_CODE = "BUILD_FAILED_RECOVERABLE" + +export function isRecoverableBuildFailure( + cliError?: { code?: string } | null, +): boolean { + return cliError?.code === RECOVERABLE_BUILD_FAILURE_CODE +} + export function isCommandSuccess(message: string | undefined | null): boolean { if (!message) return false const { message: body } = parseLogLine(message) @@ -21,6 +29,23 @@ export function isCommandSuccess(message: string | undefined | null): boolean { return body === "Exit code: 0" } +// Reads the container's actual recovery state from the success envelope, or +// null when the message is not a parseable success envelope. +export function parseRecoveryContainer( + message: string | undefined | null, +): boolean | null { + if (!message) return null + const { message: body } = parseLogLine(message) + if (!body.startsWith("{")) return null + try { + const envelope = JSON.parse(body) + if (envelope.outcome === "success") return envelope.recovery === true + } catch { + // not valid JSON + } + return null +} + export interface ParsedLogLine { time: string level: "info" | "warn" | "fatal" | "debug" | "error" | "" diff --git a/desktop/src/renderer/src/pages/SettingsPage.svelte b/desktop/src/renderer/src/pages/SettingsPage.svelte index 052262268..9c0ecf5f1 100644 --- a/desktop/src/renderer/src/pages/SettingsPage.svelte +++ b/desktop/src/renderer/src/pages/SettingsPage.svelte @@ -28,7 +28,9 @@ import type { ColorScheme, UIScale, LocalOptions, + OnBuildFailure, } from "$lib/stores/settings.js" +import * as Select from "$lib/components/ui/select/index.js" import UpdatesPanel from "$lib/components/update/UpdatesPanel.svelte" import { Skeleton } from "$lib/components/ui/skeleton/index.js" import { toasts } from "$lib/stores/toasts.js" @@ -116,8 +118,15 @@ let local = $state({ additionalCliFlags: "", additionalEnvVars: "", experimentalMultiDevcontainer: false, + onBuildFailure: "prompt", }) +const ON_BUILD_FAILURE_OPTIONS: { value: OnBuildFailure; label: string }[] = [ + { value: "prompt", label: "Prompt with recovery options" }, + { value: "auto-recovery", label: "Automatically open recovery container" }, + { value: "nothing", label: "Do nothing" }, +] + const shortcuts = [ { keys: "Cmd/Ctrl + K", action: "Open command palette" }, { keys: "Cmd/Ctrl + N", action: "New workspace" }, @@ -198,6 +207,32 @@ function toggleLocal(key: keyof LocalOptions) { toggleLocal("debugFlag")} disabled={loading || saving} /> +
+
+ +

What to do when a dev container build fails

+
+ { + if (v) { + local.onBuildFailure = v as OnBuildFailure + saveLocal("onBuildFailure", v) + } + }} + > + + {ON_BUILD_FAILURE_OPTIONS.find((o) => o.value === local.onBuildFailure)?.label ?? "Prompt with recovery options"} + + + {#each ON_BUILD_FAILURE_OPTIONS as o (o.value)} + + {/each} + + +
+

Path to SSH key for signing Git commits

diff --git a/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte b/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte index 060a1e730..eda76ee23 100644 --- a/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte +++ b/desktop/src/renderer/src/pages/WorkspaceDetailPage.svelte @@ -6,11 +6,13 @@ import { ChevronsUpDown, ClipboardCopy, Ellipsis, + LifeBuoy, Monitor, Pencil, Play, RefreshCw, RotateCcw, + ScrollText, Square, SquareTerminal, Trash2, @@ -41,6 +43,7 @@ import { workspaceDelete, workspaceRename, workspaceSetIde, + workspaceStatus, workspaceLogsList, workspaceLogRead, workspaceLogDelete, @@ -50,14 +53,21 @@ import { loadLocalOptions, getWorkspaceFolder, setWorkspaceFolder, + getWorkspaceRecoveryState, + setWorkspaceRecoveryState, } from "$lib/stores/settings.js" import { toasts } from "$lib/stores/toasts.js" import { extractErrorMessage } from "$lib/utils/error.js" import { trackEngagement } from "$lib/analytics.js" -import type { LogEntry } from "$lib/types/index.js" +import type { CommandProgress, LogEntry } from "$lib/types/index.js" import type { UnlistenFn } from "$lib/ipc/types.js" import { formatTimestamp } from "$lib/utils/time.js" -import { isCommandSuccess, stripAnsi } from "$lib/utils/log-parser.js" +import { + isRecoverableBuildFailure, + isCommandSuccess, + parseRecoveryContainer, + stripAnsi, +} from "$lib/utils/log-parser.js" import { Skeleton } from "$lib/components/ui/skeleton/index.js" let { params = {} }: { params?: Record } = $props() @@ -116,11 +126,47 @@ function statusBadgeVariant(): "default" | "secondary" | "outline" { return "outline" } +const BUILD_OPS = new Set(["Start", "Open IDE", "Recovery", "Rebuild", "Reset"]) + let activeTab = $state("overview") let outputLines = $state([]) let commandId = $state(null) let operationLabel = $state("") let operationRunning = $state(false) +let buildFailed = $state(false) +let inRecovery = $state(false) +// True only for a buildFailed loaded from persistence, so the reconciliation +// clears stale banners from external rebuilds without touching in-app failures. +let staleReconcilePending = $state(false) + +function persistRecovery() { + setWorkspaceRecoveryState(id, { buildFailed, inRecovery }) +} + +// Reconcile inRecovery against the container's persisted recovery state (set by +// the last up, in-app or external), so a rebuild done outside the app is reflected. +async function reconcileRecoveryFromStatus() { + try { + const status = JSON.parse(await workspaceStatus(id, true)) as { + state?: string + recovery?: boolean + } + if (status.state === "Running") { + inRecovery = status.recovery === true + persistRecovery() + } + } catch { + // status unavailable; keep persisted state + } +} + +$effect(() => { + if (staleReconcilePending && isRunning && !operationRunning) { + staleReconcilePending = false + buildFailed = false + persistRecovery() + } +}) let unlisten: UnlistenFn | null = null let pendingLines: string[] = [] let flushHandle: number | null = null @@ -206,10 +252,17 @@ onMount(async () => { const success = isCommandSuccess(progress.message) if (success) { toasts.success(`${operationLabel} ${id} succeeded`) + if (BUILD_OPS.has(operationLabel)) { + buildFailed = false + const recovered = parseRecoveryContainer(progress.message) + inRecovery = recovered ?? operationLabel === "Recovery" + persistRecovery() + } } else { toasts.error( `${operationLabel} ${id} failed. Check output for details.`, ) + handleBuildFailure(progress) } if (operationLabel === "Delete" && success) { goto("/workspaces") @@ -225,18 +278,26 @@ onMount(async () => { loadLogs() customFolder = getWorkspaceFolder(id) + const rec = getWorkspaceRecoveryState(id) + buildFailed = rec.buildFailed ?? false + inRecovery = rec.inRecovery ?? false + staleReconcilePending = buildFailed + void reconcileRecoveryFromStatus() - // Auto-trigger IDE open when navigated with ?action=open-ide const qs = new URLSearchParams($querystring ?? "") const action = qs.get("action") - if (action === "open-ide") { + if (action === "open-ide" || action === "start") { // Clear query param so refresh doesn't re-trigger history.replaceState( {}, "", window.location.pathname + window.location.hash.split("?")[0], ) - handleOpenIde() + if (action === "open-ide") { + handleOpenIde() + } else { + handleStart() + } } }) @@ -346,6 +407,8 @@ function isDebug(): boolean { function startStreamingOp(label: string) { operationLabel = label operationRunning = true + buildFailed = false + persistRecovery() outputLines = [] pendingLines = [] if (flushHandle !== null) { @@ -372,6 +435,45 @@ async function handleStart() { } } +function handleBuildFailure(progress: CommandProgress) { + if ( + !BUILD_OPS.has(operationLabel) || + !isRecoverableBuildFailure(progress.cliError) + ) { + return + } + const pref = loadLocalOptions().onBuildFailure + if (pref === "nothing") return + // Avoid a loop: don't auto-retry recovery when recovery itself failed. + if (pref === "auto-recovery" && operationLabel !== "Recovery") { + void handleRecovery() + return + } + buildFailed = true + inRecovery = false + persistRecovery() +} + +async function handleRecovery() { + const ide = currentIde + const folder = customFolder || undefined + startStreamingOp("Recovery") + try { + commandId = await workspaceUp({ + source: id, + ide, + recovery: true, + debug: isDebug(), + workspaceFolder: folder, + }) + } catch (err) { + operationRunning = false + toasts.error( + `Failed to start recovery container: ${extractErrorMessage(err)}`, + ) + } +} + async function handleOpenIde() { const ide = currentIde const folder = customFolder || undefined @@ -514,6 +616,12 @@ async function handleRenameConfirmed() { {#if workspace.status} {workspace.status} {/if} + {#if inRecovery} + + + Recovery mode + + {/if}
@@ -600,6 +708,48 @@ async function handleRenameConfirmed() {
+ {#if buildFailed} +
+
+ +
+

Dev container build failed

+

Open a recovery container (features and lifecycle commands disabled) to repair devcontainer.json, or retry the build.

+
+
+ + {#if operationLabel !== "Recovery"} + + {/if} + + + +
+ {:else if inRecovery} +
+
+ +
+

Running in recovery mode

+

Features and lifecycle commands are disabled. Fix devcontainer.json, then rebuild the full container.

+
+
+ +
+ {/if} + Overview diff --git a/e2e/tests/up/handles_errors.go b/e2e/tests/up/handles_errors.go index 61c7dde6f..d00b5059f 100644 --- a/e2e/tests/up/handles_errors.go +++ b/e2e/tests/up/handles_errors.go @@ -2,14 +2,20 @@ package up import ( "context" + "errors" "os" + "os/exec" "strings" "github.com/devsy-org/devsy/e2e/framework" + "github.com/devsy-org/devsy/pkg/exitcode" + "github.com/devsy-org/devsy/pkg/flags/names" "github.com/onsi/ginkgo/v2" "github.com/onsi/gomega" ) +const cmdWorkspace = "workspace" + var _ = ginkgo.Describe( "testing up command that handles workspace errors", ginkgo.Label("up-handle-errors"), @@ -123,5 +129,50 @@ var _ = ginkgo.Describe( framework.ExpectNoError(err) framework.ExpectEqual(out, initialList) }, ginkgo.SpecTimeout(framework.TimeoutShort())) + + ginkgo.It( + "launches a recovery container after a failed build", + func(ctx context.Context) { + f, err := setupDockerProvider(initialDir+"/bin", "docker") + framework.ExpectNoError(err) + ginkgo.DeferCleanup(func(cleanupCtx context.Context) { + _ = f.DevsyProviderDelete(cleanupCtx, "docker") + }) + + tempDir, err := framework.CopyToTempDir( + "tests/up/testdata/docker-recovery", + ) + framework.ExpectNoError(err) + ginkgo.DeferCleanup(framework.CleanupTempDir, initialDir, tempDir) + ginkgo.DeferCleanup(func(cleanupCtx context.Context) { + _ = f.DevsyWorkspaceDelete(cleanupCtx, tempDir, "--force") + }) + + _, _, err = f.ExecCommandCapture(ctx, []string{ + cmdWorkspace, "up", + names.Flag(names.Debug), + names.Flag(names.IDE), "none", + tempDir, + }) + var exitErr *exec.ExitError + gomega.Expect(errors.As(err, &exitErr)).To(gomega.BeTrue(), + "expected an exec.ExitError, got %v", err) + gomega.Expect(exitErr.ExitCode()).To( + gomega.Equal(exitcode.BuildFailedRecoverable), + "failed build should exit with the recoverable code") + + err = f.DevsyUp(ctx, tempDir, names.Flag(names.Recovery)) + framework.ExpectNoError(err) + + out, err := f.DevsySSH(ctx, tempDir, "echo -n recovered") + framework.ExpectNoError(err) + framework.ExpectEqual( + out, + "recovered", + "recovery container should be reachable", + ) + }, + ginkgo.SpecTimeout(framework.TimeoutLong()), + ) }, ) diff --git a/e2e/tests/up/testdata/docker-recovery/.devcontainer.json b/e2e/tests/up/testdata/docker-recovery/.devcontainer.json new file mode 100644 index 000000000..5c9787542 --- /dev/null +++ b/e2e/tests/up/testdata/docker-recovery/.devcontainer.json @@ -0,0 +1,6 @@ +{ + "name": "recovery-test", + "build": { + "dockerfile": "Dockerfile" + } +} diff --git a/e2e/tests/up/testdata/docker-recovery/Dockerfile b/e2e/tests/up/testdata/docker-recovery/Dockerfile new file mode 100644 index 000000000..7c5a7358b --- /dev/null +++ b/e2e/tests/up/testdata/docker-recovery/Dockerfile @@ -0,0 +1,4 @@ +FROM ghcr.io/devsy-org/test-images/base:ubuntu + +# Always fails the build so recovery mode can be exercised. +RUN echo "intentional build failure" && exit 1 diff --git a/pkg/client/client.go b/pkg/client/client.go index 637fd29b0..55fdc8ce1 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -213,6 +213,7 @@ type WorkspaceStatus struct { Context string `json:"context,omitempty"` Provider string `json:"provider,omitempty"` State string `json:"state,omitempty"` + Recovery bool `json:"recovery,omitempty"` } type User struct { diff --git a/pkg/clierr/errors.go b/pkg/clierr/errors.go index 58dbb6c6c..ebbf49195 100644 --- a/pkg/clierr/errors.go +++ b/pkg/clierr/errors.go @@ -12,9 +12,10 @@ import ( type Code string const ( - CodeRateLimited Code = "RATE_LIMITED" - CodePanic Code = "PANIC" - CodeUnknown Code = "UNKNOWN" + CodeRateLimited Code = "RATE_LIMITED" + CodePanic Code = "PANIC" + CodeUnknown Code = "UNKNOWN" + CodeBuildFailedRecoverable Code = "BUILD_FAILED_RECOVERABLE" ) type CLIError struct { @@ -64,6 +65,23 @@ func (e *CLIError) MarshalLogObject(enc zapcore.ObjectEncoder) error { var ErrRateLimited = errors.New("rate limited") +var ErrBuildFailedRecoverable = errors.New("dev container build failed") + +type recoverableBuildError struct{ err error } + +func (e recoverableBuildError) Error() string { return e.err.Error() } +func (e recoverableBuildError) Unwrap() error { return e.err } +func (e recoverableBuildError) Is(target error) bool { + return target == ErrBuildFailedRecoverable +} + +func Recoverable(err error) error { + if err == nil { + return nil + } + return recoverableBuildError{err: err} +} + func Classify(err error) *CLIError { if err == nil { return nil @@ -74,6 +92,14 @@ func Classify(err error) *CLIError { return cliErr } + if errors.Is(err, ErrBuildFailedRecoverable) { + return &CLIError{ + Code: CodeBuildFailedRecoverable, + Message: err.Error(), + wrapped: err, + } + } + if errors.Is(err, ErrRateLimited) { return &CLIError{ Code: CodeRateLimited, diff --git a/pkg/clierr/errors_test.go b/pkg/clierr/errors_test.go index 5235f91d5..6f1177631 100644 --- a/pkg/clierr/errors_test.go +++ b/pkg/clierr/errors_test.go @@ -66,6 +66,33 @@ func TestCLIError_UnwrapPreservesChain(t *testing.T) { } } +func TestClassify_RecoverableBuildFailure(t *testing.T) { + got := Classify(Recoverable(fmt.Errorf("build image: boom"))) + if got.Code != CodeBuildFailedRecoverable { + t.Fatalf("Code = %q, want %q", got.Code, CodeBuildFailedRecoverable) + } + if got.Message != "build image: boom" { + t.Fatalf("Message = %q, want the original message", got.Message) + } +} + +func TestClassify_RecoverableThroughWrap(t *testing.T) { + wrapped := fmt.Errorf( + "start workspace: %w: %w", + Recoverable(fmt.Errorf("build image: boom")), + fmt.Errorf("agent exited"), + ) + if got := Classify(wrapped); got.Code != CodeBuildFailedRecoverable { + t.Fatalf("Code = %q, want %q", got.Code, CodeBuildFailedRecoverable) + } +} + +func TestRecoverable_NilStaysNil(t *testing.T) { + if Recoverable(nil) != nil { + t.Fatal("Recoverable(nil) should be nil") + } +} + func TestNewPanic(t *testing.T) { got := NewPanic("boom") if got.Code != CodePanic { diff --git a/pkg/config/labels.go b/pkg/config/labels.go index 867b9b68e..799206bd3 100644 --- a/pkg/config/labels.go +++ b/pkg/config/labels.go @@ -9,6 +9,7 @@ const ( DockerResourceLabel = ReverseDomain + ".resource" DockerVolumeRoleLabel = ReverseDomain + ".volume-role" DockerSeededLabel = ReverseDomain + ".seeded" + DockerRecoveryLabel = ReverseDomain + ".recovery" DockerUserLabel = BinaryName + ".user" K8sCreatedLabel = Domain + "/created" diff --git a/pkg/devcontainer/config/envelope.go b/pkg/devcontainer/config/envelope.go index 6bf28b484..ac604da7c 100644 --- a/pkg/devcontainer/config/envelope.go +++ b/pkg/devcontainer/config/envelope.go @@ -13,6 +13,7 @@ type ResultEnvelope struct { RemoteWorkspaceFolder string `json:"remoteWorkspaceFolder"` URL string `json:"url,omitempty"` Warnings []string `json:"warnings,omitempty"` + Recovery bool `json:"recovery,omitempty"` } type ErrorEnvelope struct { diff --git a/pkg/devcontainer/config/envelope_test.go b/pkg/devcontainer/config/envelope_test.go index 59a6f255d..a006dc3fb 100644 --- a/pkg/devcontainer/config/envelope_test.go +++ b/pkg/devcontainer/config/envelope_test.go @@ -197,3 +197,18 @@ func TestWriteErrorJSON(t *testing.T) { }) } } + +func TestWriteResultJSON_Recovery(t *testing.T) { + var buf bytes.Buffer + if err := WriteResultJSON(&buf, ResultEnvelope{Recovery: true}); err != nil { + t.Fatalf("WriteResultJSON: %v", err) + } + + var envelope ResultEnvelope + if err := json.Unmarshal(buf.Bytes(), &envelope); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if !envelope.Recovery { + t.Error("recovery field did not round-trip") + } +} diff --git a/pkg/devcontainer/config/result.go b/pkg/devcontainer/config/result.go index 0b75bf554..8c97beb89 100644 --- a/pkg/devcontainer/config/result.go +++ b/pkg/devcontainer/config/result.go @@ -5,6 +5,7 @@ import ( "maps" "slices" + "github.com/devsy-org/devsy/pkg/clierr" pkgconfig "github.com/devsy-org/devsy/pkg/config" ) @@ -20,6 +21,9 @@ func (r *Result) Err() error { if r == nil || r.Error == "" { return nil } + if r.RecoveryAvailable { + return clierr.Recoverable(errors.New(r.Error)) + } return errors.New(r.Error) } @@ -29,13 +33,9 @@ type Result struct { SubstitutionContext *SubstitutionContext `json:"SubstitutionContext"` ContainerDetails *ContainerDetails `json:"ContainerDetails"` HostWarnings []string `json:"HostWarnings,omitempty"` - // Error, when non-empty, indicates the agent failed to produce a usable - // devcontainer result and carries the underlying error message. This - // allows structured errors (e.g. host requirements not met) to flow back - // to the host side via the tunnel before the agent process exits, instead - // of being lost to a generic "did not receive a result back from agent" - // fallback. - Error string `json:"Error,omitempty"` + RecoveryContainer bool `json:"RecoveryContainer,omitempty"` + Error string `json:"Error,omitempty"` + RecoveryAvailable bool `json:"RecoveryAvailable,omitempty"` } type DevContainerConfigWithPath struct { diff --git a/pkg/devcontainer/config/result_test.go b/pkg/devcontainer/config/result_test.go index b6e865312..602646951 100644 --- a/pkg/devcontainer/config/result_test.go +++ b/pkg/devcontainer/config/result_test.go @@ -1,6 +1,28 @@ package config -import "testing" +import ( + "errors" + "testing" + + "github.com/devsy-org/devsy/pkg/clierr" +) + +const errBoom = "boom" + +func TestResultErrRecoveryAvailable(t *testing.T) { + err := (&Result{Error: "build image: boom", RecoveryAvailable: true}).Err() + if err == nil || err.Error() != "build image: boom" { + t.Fatalf("Err() = %v, want message preserved", err) + } + if !errors.Is(err, clierr.ErrBuildFailedRecoverable) { + t.Fatal("recovery-available error must classify as recoverable") + } + + plain := (&Result{Error: errBoom}).Err() + if errors.Is(plain, clierr.ErrBuildFailedRecoverable) { + t.Fatal("plain error must not be recoverable") + } +} func TestResultErr(t *testing.T) { tests := []struct { @@ -11,7 +33,7 @@ func TestResultErr(t *testing.T) { }{ {name: "nil result", result: nil, wantErr: false}, {name: "no error", result: &Result{}, wantErr: false}, - {name: "with error", result: &Result{Error: "boom"}, wantErr: true, wantMsg: "boom"}, + {name: "with error", result: &Result{Error: errBoom}, wantErr: true, wantMsg: errBoom}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/devcontainer/run.go b/pkg/devcontainer/run.go index eb29045fc..ea678cb35 100644 --- a/pkg/devcontainer/run.go +++ b/pkg/devcontainer/run.go @@ -6,6 +6,7 @@ import ( "io" "time" + "github.com/devsy-org/devsy/pkg/clierr" "github.com/devsy-org/devsy/pkg/devcontainer/config" "github.com/devsy-org/devsy/pkg/driver" "github.com/devsy-org/devsy/pkg/driver/drivercreate" @@ -80,6 +81,8 @@ type runner struct { id string idLabels []string + + recovering bool } func NewRunner( @@ -134,8 +137,12 @@ func (r *runner) Up( } defer cleanupBuildInformation(substitutedConfig.Config) - if err := r.runInitializeCommand(ctx, substitutedConfig.Config, options); err != nil { - return nil, err + // Recovery skips initializeCommand: a failing host hook must not block the + // recovery container. In normal mode its failure is recovery-eligible. + if !options.Recovery { + if err := r.runInitializeCommand(ctx, substitutedConfig.Config, options); err != nil { + return nil, clierr.Recoverable(fmt.Errorf("initialize command: %w", err)) + } } params := &runContainerParams{ @@ -145,16 +152,11 @@ func (r *runner) Up( timeout: timeout, } - switch { - case isDockerFileConfig(substitutedConfig.Config), - substitutedConfig.Config.Image != "", - substitutedConfig.Config.ContainerID != "": - return r.runSingleContainer(ctx, params) - case isDockerComposeConfig(substitutedConfig.Config): - return r.runDockerCompose(ctx, params) - default: - return r.runDefaultContainer(ctx, params) + result, err := r.dispatchByConfigKind(ctx, substitutedConfig, params) + if result != nil { + result.RecoveryContainer = r.recovering } + return result, err } func (r *runner) Command(ctx context.Context, params CommandParams) error { @@ -180,6 +182,30 @@ func (r *runner) Logs(ctx context.Context, writer io.Writer) error { return r.driver.GetDevContainerLogs(ctx, r.id, writer, writer) } +// dispatchByConfigKind routes to the container implementation for the config's +// kind (image/Dockerfile, compose, or default/auto-detected). +func (r *runner) dispatchByConfigKind( + ctx context.Context, + substitutedConfig *config.SubstitutedConfig, + params *runContainerParams, +) (*config.Result, error) { + switch { + case isDockerFileConfig(substitutedConfig.Config), + substitutedConfig.Config.Image != "", + substitutedConfig.Config.ContainerID != "": + return r.runSingleContainer(ctx, params) + case isDockerComposeConfig(substitutedConfig.Config): + if params.options.Recovery { + log.Warn( + "recovery mode is not supported for docker-compose dev containers; proceeding without it", + ) + } + return r.runDockerCompose(ctx, params) + default: + return r.runDefaultContainer(ctx, params) + } +} + // runInitializeCommand runs the host-side initializeCommand hook. The hook is // never executed in platform mode. func (r *runner) runInitializeCommand( diff --git a/pkg/devcontainer/single.go b/pkg/devcontainer/single.go index eb41dd084..00b606985 100644 --- a/pkg/devcontainer/single.go +++ b/pkg/devcontainer/single.go @@ -9,18 +9,22 @@ import ( "strings" "github.com/devsy-org/devsy/pkg/agent/delivery" + "github.com/devsy-org/devsy/pkg/clierr" "github.com/devsy-org/devsy/pkg/command" pkgconfig "github.com/devsy-org/devsy/pkg/config" "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/driver" + "github.com/devsy-org/devsy/pkg/language" "github.com/devsy-org/devsy/pkg/log" "github.com/devsy-org/devsy/pkg/telemetry/distinctid" ) var dockerlessImage = "ghcr.io/devsy-org/dockerless:0.2.0" +var defaultRecoveryImage = language.MapConfig[language.None].Image + const ( DevsyExtraEnvVar = "DEVSY" RemoteContainersExtraEnvVar = "REMOTE_CONTAINERS" @@ -151,6 +155,10 @@ func (r *runner) resolveExistingContainer( containerDetails *config.ContainerDetails, p *resolveParams, ) (*resolvedContainer, error) { + if isRecoveryContainer(containerDetails) { + r.recovering = true + } + containerDetails, err := r.ensureRunning(ctx, containerDetails) if err != nil { return nil, err @@ -339,14 +347,25 @@ func (r *runner) buildNewContainerConfig( ctx context.Context, p *resolveParams, ) (*config.BuildInfo, *config.MergedDevContainerConfig, error) { + activeConfig := p.parsedConfig + buildInfo, err := r.build( ctx, - p.parsedConfig, + activeConfig, p.substitutionContext, p.options.toBuildOptions(), ) if err != nil { - return nil, nil, fmt.Errorf("build image: %w", err) + if !p.options.Recovery { + log.Info("dev container build failed; re-run with --recovery to " + + "start a recovery container with features and lifecycle commands disabled") + return nil, nil, clierr.Recoverable(fmt.Errorf("build image: %w", err)) + } + buildInfo, activeConfig, err = r.buildRecoveryContainerConfig(ctx, p, err) + if err != nil { + return nil, nil, err + } + r.recovering = true } if p.options.Recreate { @@ -356,7 +375,7 @@ func (r *runner) buildNewContainerConfig( } mergedConfig, err := config.MergeConfiguration( - p.parsedConfig.Config, + activeConfig.Config, buildInfo.ImageMetadata.Config, ) if err != nil { @@ -374,6 +393,61 @@ func (r *runner) buildNewContainerConfig( return buildInfo, mergedConfig, nil } +// buildRecoveryContainerConfig rebuilds from a stripped-down config after a +// failed build, returning the recovery build info and the config that produced it. +func (r *runner) buildRecoveryContainerConfig( + ctx context.Context, + p *resolveParams, + buildErr error, +) (*config.BuildInfo, *config.SubstitutedConfig, error) { + log.Warnf("dev container build failed: %v", buildErr) + log.Warn("recovery mode enabled: retrying with features and lifecycle commands " + + "disabled so the workspace can start; fix devcontainer.json and rebuild to " + + "restore the full container") + + recoveryConfig := recoveryDevContainerConfig(p.parsedConfig) + + buildInfo, err := r.build( + ctx, + recoveryConfig, + p.substitutionContext, + p.options.toBuildOptions(), + ) + if err != nil { + return nil, nil, fmt.Errorf( + "build recovery image: %w (original build error: %v)", err, buildErr, + ) + } + + return buildInfo, recoveryConfig, nil +} + +// recoveryDevContainerConfig strips features and lifecycle hooks, keeping a +// plain image but swapping a (possibly broken) Dockerfile for a known-good image. +// isRecoveryContainer reports whether an existing container was built in +// recovery mode, read from the label stamped at run time. +func isRecoveryContainer(details *config.ContainerDetails) bool { + return details != nil && + details.Config.Labels[pkgconfig.DockerRecoveryLabel] == pkgconfig.LabelValueTrue +} + +func recoveryDevContainerConfig(parsed *config.SubstitutedConfig) *config.SubstitutedConfig { + cloned := config.CloneDevContainerConfig(parsed.Config) + cloned.Features = nil + cloned.OverrideFeatureInstallOrder = nil + cloned.DevContainerActions = config.DevContainerActions{} + + if cloned.Image == "" { + cloned.DockerfileContainer = config.DockerfileContainer{} + cloned.Image = defaultRecoveryImage + } + + return &config.SubstitutedConfig{ + Config: cloned, + Raw: parsed.Raw, + } +} + // newContainerHostWarnings validates host requirements for a new container, // returning warnings. Unmet requirements error unless SkipHostRequirements is // set, in which case the error is downgraded to a warning. @@ -731,6 +805,9 @@ func (r *runner) getRunOptions( metadata.ImageMetadataLabel + "=" + string(marshalled), config.UserLabel + "=" + imageUser, } + if r.recovering { + labels = append(labels, pkgconfig.DockerRecoveryLabel+"="+pkgconfig.LabelValueTrue) + } user := imageUser if mergedConfig.ContainerUser != "" { diff --git a/pkg/devcontainer/single_test.go b/pkg/devcontainer/single_test.go index 6c6c70db6..bbff6e821 100644 --- a/pkg/devcontainer/single_test.go +++ b/pkg/devcontainer/single_test.go @@ -3,7 +3,9 @@ package devcontainer import ( "testing" + pkgconfig "github.com/devsy-org/devsy/pkg/config" "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/devsy-org/devsy/pkg/types" ) const mountTypeVolume = "volume" @@ -120,3 +122,104 @@ func TestWithResolvedUser(t *testing.T) { t.Error("source config must not be mutated") } } + +func TestRecoveryDevContainerConfig(t *testing.T) { + source := &config.DevContainerConfig{} + source.Image = "mcr.microsoft.com/devcontainers/go:1.26" + source.Name = "my-project" + source.Features = map[string]any{ + "ghcr.io/devcontainers-extra/features/go-task:1": map[string]any{}, + } + source.OverrideFeatureInstallOrder = []string{"ghcr.io/devcontainers-extra/features/go-task"} + source.PostCreateCommand = types.LifecycleHook{"install": {"npm install"}} + source.OnCreateCommand = types.LifecycleHook{"setup": {"echo hi"}} + + parsed := &config.SubstitutedConfig{Config: source, Raw: source} + + got := recoveryDevContainerConfig(parsed) + + if len(got.Config.Features) != 0 { + t.Errorf("Features must be cleared, got %v", got.Config.Features) + } + if len(got.Config.OverrideFeatureInstallOrder) != 0 { + t.Errorf( + "OverrideFeatureInstallOrder must be cleared, got %v", + got.Config.OverrideFeatureInstallOrder, + ) + } + if len(got.Config.PostCreateCommand) != 0 || len(got.Config.OnCreateCommand) != 0 { + t.Error("lifecycle hooks must be cleared") + } + if got.Config.Image != source.Image { + t.Errorf("Image must be preserved, got %q", got.Config.Image) + } + if got.Config.Name != source.Name { + t.Errorf("Name must be preserved, got %q", got.Config.Name) + } + if got.Raw != parsed.Raw { + t.Error("Raw config must be preserved") + } +} + +func TestRecoveryDevContainerConfigNoMutation(t *testing.T) { + source := &config.DevContainerConfig{} + source.Features = map[string]any{"ghcr.io/x/y:1": map[string]any{}} + source.PostCreateCommand = types.LifecycleHook{"install": {"npm install"}} + + recoveryDevContainerConfig(&config.SubstitutedConfig{Config: source, Raw: source}) + + if len(source.Features) == 0 { + t.Error("source Features must not be mutated") + } + if len(source.PostCreateCommand) == 0 { + t.Error("source lifecycle hooks must not be mutated") + } +} + +func TestRecoveryDevContainerConfigDockerfile(t *testing.T) { + source := &config.DevContainerConfig{} + source.Dockerfile = "Dockerfile" + source.Context = "." + source.Features = map[string]any{"ghcr.io/x/y:1": map[string]any{}} + + parsed := &config.SubstitutedConfig{Config: source, Raw: source} + + got := recoveryDevContainerConfig(parsed) + + if got.Config.Image != defaultRecoveryImage { + t.Errorf( + "Image = %q, want default recovery image %q", + got.Config.Image, + defaultRecoveryImage, + ) + } + if got.Config.Dockerfile != "" || got.Config.Context != "" { + t.Error("Dockerfile build fields must be cleared") + } + if len(got.Config.Features) != 0 { + t.Error("Features must be cleared") + } + if source.Dockerfile != "Dockerfile" { + t.Error("source config must not be mutated") + } +} + +func TestIsRecoveryContainer(t *testing.T) { + if isRecoveryContainer(nil) { + t.Error("nil details must not be a recovery container") + } + + plain := &config.ContainerDetails{} + if isRecoveryContainer(plain) { + t.Error("container without the recovery label must not be flagged") + } + + recovery := &config.ContainerDetails{ + Config: config.ContainerDetailsConfig{ + Labels: map[string]string{pkgconfig.DockerRecoveryLabel: pkgconfig.LabelValueTrue}, + }, + } + if !isRecoveryContainer(recovery) { + t.Error("container with the recovery label must be flagged") + } +} diff --git a/pkg/exitcode/codes.go b/pkg/exitcode/codes.go index e391865ad..0c4cdf870 100644 --- a/pkg/exitcode/codes.go +++ b/pkg/exitcode/codes.go @@ -7,4 +7,8 @@ const ( // Retryable marks a transient failure the caller may retry (sysexits EX_TEMPFAIL). Retryable = 75 + + // BuildFailedRecoverable marks a build failure retryable with --recovery. + // 79 is just past the sysexits range (64–78). + BuildFailedRecoverable = 79 ) diff --git a/pkg/flags/names/names.go b/pkg/flags/names/names.go index d78612534..1622e54c3 100644 --- a/pkg/flags/names/names.go +++ b/pkg/flags/names/names.go @@ -51,6 +51,7 @@ const ( Pull = "pull" PullFromInsideContainer = "pull-from-inside-container" Reconfigure = "reconfigure" + Recovery = "recovery" Recreate = "recreate" RemoteUser = "remote-user" RemoveVolumes = "remove-volumes" diff --git a/pkg/provider/workspace.go b/pkg/provider/workspace.go index 197910ad0..8b1caef07 100644 --- a/pkg/provider/workspace.go +++ b/pkg/provider/workspace.go @@ -243,6 +243,7 @@ type CLIOptions struct { FeatureSecretsFile string `json:"featureSecretsFile,omitempty"` InitEnv []string `json:"initEnv,omitempty"` Recreate bool `json:"recreate,omitempty"` + Recovery bool `json:"recovery,omitempty"` Prebuild bool `json:"prebuild,omitempty"` Reset bool `json:"reset,omitempty"` NoAutoStart bool `json:"noAutoStart,omitempty"`