diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 0087c81b..19e7c263 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -191,7 +191,7 @@ jobs: e2e-runtime-volumes-ssh: name: E2E (Runtime volumes + SSH) runs-on: ubuntu-latest - timeout-minutes: 40 + timeout-minutes: 60 steps: - uses: actions/checkout@v6 - name: Install dependencies diff --git a/docs/pr-screenshots/issue-232/project-apply-actions.png b/docs/pr-screenshots/issue-232/project-apply-actions.png new file mode 100644 index 00000000..437feeff Binary files /dev/null and b/docs/pr-screenshots/issue-232/project-apply-actions.png differ diff --git a/docs/pr-screenshots/issue-232/terminal-apply-action.png b/docs/pr-screenshots/issue-232/terminal-apply-action.png new file mode 100644 index 00000000..65a1c4ff Binary files /dev/null and b/docs/pr-screenshots/issue-232/terminal-apply-action.png differ diff --git a/packages/api/src/http.ts b/packages/api/src/http.ts index 266735a7..3083e426 100644 --- a/packages/api/src/http.ts +++ b/packages/api/src/http.ts @@ -69,6 +69,7 @@ import { } from "./services/federation.js" import { applyAllProjects, + applyProjectById, createProjectFromRequest, deleteProjectById, downAllProjects, @@ -972,6 +973,14 @@ export const makeRouter = () => { Effect.catchAll(errorResponse) ) ), + HttpRouter.post( + "/projects/:projectId/apply", + projectParams.pipe( + Effect.flatMap(({ projectId }) => applyProjectById(projectId)), + Effect.flatMap((project) => jsonResponse({ ok: true, project }, 200)), + Effect.catchAll(errorResponse) + ) + ), HttpRouter.post( "/projects/:projectId/down", projectParams.pipe( diff --git a/packages/api/src/services/projects.ts b/packages/api/src/services/projects.ts index cb0266ae..811057e7 100644 --- a/packages/api/src/services/projects.ts +++ b/packages/api/src/services/projects.ts @@ -1,5 +1,6 @@ import { type AppError, + applyProjectConfig, buildCreateCommand, defaultTemplateConfig, createProject, @@ -588,6 +589,28 @@ export const applyAllProjects = (activeOnly: boolean) => activeOnly }) +export const applyProjectById = ( + projectId: string +) => + Effect.gen(function*(_) { + const project = yield* _(findProjectById(projectId)) + yield* _(markDeployment(projectId, "apply", "docker-git apply")) + yield* _( + runWithProjectEventLogs( + projectId, + applyProjectConfig({ + _tag: "Apply", + projectDir: project.projectDir, + runUp: true + }) + ) + ) + const details = yield* _(runtimeProjectDetails(project)) + yield* _(recordProjectStartedFromDetails(project, details, "up")) + yield* _(markDeployment(projectId, "running", "Apply completed")) + return details + }).pipe(Effect.mapError(toProjectApiError)) + export const downAllProjects = () => downAllDockerGitProjects export const getProject = ( diff --git a/packages/app/eslint.config.mts b/packages/app/eslint.config.mts index 937453ca..65095463 100644 --- a/packages/app/eslint.config.mts +++ b/packages/app/eslint.config.mts @@ -279,6 +279,36 @@ export default defineConfig( }] } }, + { + files: [ + "src/docker-git/menu-create-shared.ts", + "src/docker-git/menu-render.ts", + "src/web/actions-projects.ts", + "src/web/app-ready-controller.ts", + "src/web/app-ready-main-panels.tsx", + "src/web/app-ready-ssh-link-hook.ts", + "src/web/app-ready-terminal-screen.tsx", + "src/web/app-ready-url.ts", + "src/web/panel-content.tsx", + "src/web/panel-create-select.tsx", + "src/web/panel-project-details.tsx", + "src/web/panel-terminal.tsx", + "src/web/terminal-mobile-controls.ts", + "src/web/terminal-panel-runtime-core.ts" + ], + rules: { + "complexity": ["error", 15], + "max-lines": [ + "error", + { max: 650, skipBlankLines: true, skipComments: true } + ], + "max-lines-per-function": [ + "error", + { max: 160, skipBlankLines: true, skipComments: true } + ], + "max-params": ["error", 6] + } + }, { files: ['**/*.{test,spec}.{ts,tsx}', 'tests/**', '**/__tests__/**'], ...vitest.configs.all, diff --git a/packages/app/src/web/actions-projects.ts b/packages/app/src/web/actions-projects.ts index acf45da2..ea58c7fb 100644 --- a/packages/app/src/web/actions-projects.ts +++ b/packages/app/src/web/actions-projects.ts @@ -13,6 +13,8 @@ import { } from "./actions-shared.js" import { loadSelectedProjectTasks } from "./actions-tasks.js" import { + applyAllProjects, + applyProject, createProjectTerminalSession, deleteProject, downAllProjects, @@ -120,6 +122,31 @@ export const connectProjectById = ( }) } +export const applyProjectById = ( + projectId: string, + context: BrowserActionContext +) => { + context.setSelectedProjectId(projectId) + withBusy({ + context, + effect: applyProject(projectId), + label: "Applying project", + onSuccess: (project) => { + context.reloadDashboard() + context.setSelectedProject(project) + context.setMessage(`Applied ${project.displayName}.`) + } + }) +} + +export const applySelectedProject = (context: BrowserActionContext) => { + const projectId = requireSelectedProjectId(context) + if (projectId === null) { + return + } + applyProjectById(projectId, context) +} + export const attachProjectTerminalById = ( projectId: string, projectKey: string, @@ -223,6 +250,21 @@ const runDownAllProjects = (context: BrowserActionContext) => { }) } +export const runApplyAllProjects = (context: BrowserActionContext) => { + if (!confirmAction("Apply docker-git config to all projects?")) { + return + } + withBusy({ + context, + effect: applyAllProjects(false), + label: "Applying all projects", + onSuccess: () => { + context.reloadDashboard() + context.setMessage("Applied docker-git config to all projects.") + } + }) +} + export const runProjectMenuAction = ( currentMenu: Exclude, context: BrowserActionContext diff --git a/packages/app/src/web/actions.ts b/packages/app/src/web/actions.ts index 08feae70..e4f09af8 100644 --- a/packages/app/src/web/actions.ts +++ b/packages/app/src/web/actions.ts @@ -30,7 +30,14 @@ export { saveSelectedDatabaseProfile } from "./actions-databases.js" export { closeSelectedProjectPort, loadSelectedProjectPorts, openSelectedProjectPort } from "./actions-port-forwards.js" -export { attachProjectTerminalById, connectProjectById, loadSelectedProjectInfo } from "./actions-projects.js" +export { + applyProjectById, + applySelectedProject, + attachProjectTerminalById, + connectProjectById, + loadSelectedProjectInfo, + runApplyAllProjects +} from "./actions-projects.js" export { loadSelectedProjectTaskLogs, loadSelectedProjectTasks, stopSelectedProjectTask } from "./actions-tasks.js" export const runBrowserMenuAction = ( diff --git a/packages/app/src/web/api-project-core.ts b/packages/app/src/web/api-project-core.ts index f6f32bb1..e5fcfeea 100644 --- a/packages/app/src/web/api-project-core.ts +++ b/packages/app/src/web/api-project-core.ts @@ -19,6 +19,15 @@ export const loadProjectLogs = (projectId: string) => Effect.map((response) => response.output) ) +export const applyProject = (projectId: string) => + requestJson( + "POST", + `/projects/${encodeURIComponent(projectId)}/apply`, + ProjectResponseSchema + ).pipe( + Effect.map((response) => response.project) + ) + export const createProject = (draft: CreateProjectDraft) => requestJson( "POST", diff --git a/packages/app/src/web/api.ts b/packages/app/src/web/api.ts index c4fc5cd0..ca28ff61 100644 --- a/packages/app/src/web/api.ts +++ b/packages/app/src/web/api.ts @@ -43,7 +43,14 @@ export { restartProjectDatabaseEditor, saveProjectDatabaseProfile } from "./api-database.js" -export { createProject, loadProjectDetails, loadProjectLogs, loadProjectPs, upProject } from "./api-project-core.js" +export { + applyProject, + createProject, + loadProjectDetails, + loadProjectLogs, + loadProjectPs, + upProject +} from "./api-project-core.js" export { loadProjectTaskLogs, loadProjectTasks, stopProjectTask } from "./api-tasks.js" export type * from "./api-types.js" @@ -204,6 +211,9 @@ export const deleteProject = (projectId: string) => export const downAllProjects = () => requestText("POST", "/projects/down-all").pipe(Effect.asVoid) +export const applyAllProjects = (activeOnly: boolean) => + requestText("POST", "/projects/apply-all", { activeOnly }).pipe(Effect.asVoid) + export const loadGithubStatus = () => requestJson("GET", "/auth/github/status", GithubStatusResponseSchema).pipe( Effect.map((response) => response.status) diff --git a/packages/app/src/web/app-ready-controller.ts b/packages/app/src/web/app-ready-controller.ts index 123e0a76..290bc6af 100644 --- a/packages/app/src/web/app-ready-controller.ts +++ b/packages/app/src/web/app-ready-controller.ts @@ -1,6 +1,8 @@ import { updateActionPromptValue } from "./action-prompt.js" import { withBusy } from "./actions-shared.js" import { + applyProjectById, + applySelectedProject, attachProjectTerminalById, cancelBrowserActionPrompt, closeSelectedProjectPort, @@ -10,6 +12,7 @@ import { openProjectBrowserById, openSelectedProjectBrowser, openSelectedProjectPort, + runApplyAllProjects, submitBrowserActionPrompt } from "./actions.js" import { deleteProjectTerminalSession } from "./api.js" @@ -254,6 +257,15 @@ const bindTerminalActions = ( actionContext: ReturnType, state: ReturnType ) => ({ + onApplyProjectById: (projectId: string) => { + applyProjectById(projectId, actionContext) + }, + onApplySelectedProject: () => { + applySelectedProject(actionContext) + }, + onApplyAllProjects: () => { + runApplyAllProjects(actionContext) + }, onOpenProjectTerminalById: (projectId: string, projectKey?: string) => { connectProjectById(projectId, actionContext, projectKey) }, diff --git a/packages/app/src/web/app-ready-layout.tsx b/packages/app/src/web/app-ready-layout.tsx index b6d023d0..5cce2f5e 100644 --- a/packages/app/src/web/app-ready-layout.tsx +++ b/packages/app/src/web/app-ready-layout.tsx @@ -46,6 +46,9 @@ export type ReadyLayoutProps = { readonly onActionPromptCancel: () => void readonly onActionPromptChange: (key: string, value: string) => void readonly onActionPromptSubmit: () => void + readonly onApplyAllProjects: () => void + readonly onApplyProjectById: (projectId: string) => void + readonly onApplySelectedProject: () => void readonly onAttachProjectTerminalSession: ( projectId: string, projectKey: string, diff --git a/packages/app/src/web/app-ready-main-panels.tsx b/packages/app/src/web/app-ready-main-panels.tsx index 131b2c3e..25293b80 100644 --- a/packages/app/src/web/app-ready-main-panels.tsx +++ b/packages/app/src/web/app-ready-main-panels.tsx @@ -79,10 +79,20 @@ const MainMenuRoute = ( const ProjectActionBar = ( { currentMenu, + onApplyAllProjects, + onApplySelectedProject, onRunCurrentMenuAction, projectBrowser, selectedProjectSummary - }: Pick + }: Pick< + MainPanelsProps, + | "currentMenu" + | "onApplyAllProjects" + | "onApplySelectedProject" + | "onRunCurrentMenuAction" + | "projectBrowser" + | "selectedProjectSummary" + > ): JSX.Element => ( {selectedProjectSummary === undefined ? "No project selected." : selectedProjectSummary.displayName} - {currentMenu === "Browser" && !canOpenProjectBrowser(projectBrowser, selectedProjectSummary?.id ?? null) - ? {actionLabel(currentMenu)} - : ( - - {actionLabel(currentMenu)} - - )} + + {currentMenu === "Select" && selectedProjectSummary !== undefined + ? ( + + Apply + + ) + : null} + {currentMenu === "Select" + ? ( + + Apply all + + ) + : null} + {currentMenu === "Browser" && !canOpenProjectBrowser(projectBrowser, selectedProjectSummary?.id ?? null) + ? {actionLabel(currentMenu)} + : ( + + {actionLabel(currentMenu)} + + )} + ) @@ -259,6 +285,8 @@ const ProjectPickerScreen = (props: MainPanelsProps): JSX.Element => ( { onOpenProjectBrowserById(browserProjectId) }} + onApplyProject={browserProjectId === undefined + ? undefined + : () => { + onApplyProjectById(browserProjectId) + }} onOpenTerminal={browserProjectId === undefined ? undefined : () => { @@ -289,6 +297,7 @@ export const TerminalScreen = (props: TerminalScreenProps): JSX.Element | null = void readonly onActionPromptChange: (key: string, value: string) => void readonly onActionPromptSubmit: () => void + readonly onApplyAllProjects: () => void + readonly onApplyProjectById: (projectId: string) => void + readonly onApplySelectedProject: () => void readonly onAttachProjectTerminalSession: ( projectId: string, projectKey: string, @@ -66,6 +69,9 @@ const readyActionProps = (actions: ReadyLayoutRenderArgs["actions"]) => ({ onActionPromptCancel: actions.onActionPromptCancel, onActionPromptChange: actions.onActionPromptChange, onActionPromptSubmit: actions.onActionPromptSubmit, + onApplyAllProjects: actions.onApplyAllProjects, + onApplyProjectById: actions.onApplyProjectById, + onApplySelectedProject: actions.onApplySelectedProject, onAttachProjectTerminalSession: actions.onAttachProjectTerminalSession, onBackScreen: actions.onBackScreen, onCloseProjectPortForward: actions.onCloseProjectPortForward, diff --git a/packages/app/src/web/app.tsx b/packages/app/src/web/app.tsx index 5fc88a52..8c7ba119 100644 --- a/packages/app/src/web/app.tsx +++ b/packages/app/src/web/app.tsx @@ -11,10 +11,17 @@ import { resolveViewportLayout, type ViewportLayout, type ViewportSize } from ". const refreshIntervalMs = 15_000 +type OptionalVisualViewportGlobal = typeof globalThis & { + readonly visualViewport?: VisualViewport | null +} + +const readVisualViewport = (global: OptionalVisualViewportGlobal): VisualViewport | null => + global.visualViewport ?? null + const resolveViewportSize = (): ViewportSize => { const layoutHeight = typeof globalThis.innerHeight === "number" ? globalThis.innerHeight : 900 const layoutWidth = typeof globalThis.innerWidth === "number" ? globalThis.innerWidth : 1280 - const visualViewport = globalThis.visualViewport + const visualViewport = readVisualViewport(globalThis) if (visualViewport === null) { return { diff --git a/packages/app/src/web/panel-terminal.tsx b/packages/app/src/web/panel-terminal.tsx index 018f8857..e64ceeeb 100644 --- a/packages/app/src/web/panel-terminal.tsx +++ b/packages/app/src/web/panel-terminal.tsx @@ -21,6 +21,7 @@ type TerminalPanelProps = { readonly keyboardOpen: boolean readonly mobileMode: boolean readonly onAttachFailure: () => void + readonly onApplyProject?: (() => void) | undefined readonly onDetach: () => void readonly onKill: () => void readonly onMessage: (message: string) => void @@ -249,14 +250,20 @@ const TerminalActionButton = ( const TerminalHeaderActions = ( { compactHeaderMode, + onApplyProject, onDetach, onKill, onOpenBrowser, onOpenTerminal, session - }: Pick & { - readonly compactHeaderMode: boolean - } + }: + & Pick< + TerminalPanelProps, + "onApplyProject" | "onDetach" | "onKill" | "onOpenBrowser" | "onOpenTerminal" | "session" + > + & { + readonly compactHeaderMode: boolean + } ): JSX.Element => (
{session.browserProjectId === undefined || onOpenBrowser === undefined @@ -266,6 +273,13 @@ const TerminalHeaderActions = ( {compactHeaderMode ? "Browser" : "Open browser"} )} + {session.browserProjectId === undefined || onApplyProject === undefined + ? null + : ( + + Apply + + )} {session.browserProjectId === undefined || onOpenTerminal === undefined ? null : ( @@ -285,21 +299,28 @@ const TerminalHeaderActions = ( const TerminalHeader = ( { compactHeaderMode, + onApplyProject, onDetach, onKill, onOpenBrowser, onOpenTerminal, session, status - }: Pick & { - readonly compactHeaderMode: boolean - readonly status: TerminalStatus - } + }: + & Pick< + TerminalPanelProps, + "onApplyProject" | "onDetach" | "onKill" | "onOpenBrowser" | "onOpenTerminal" | "session" + > + & { + readonly compactHeaderMode: boolean + readonly status: TerminalStatus + } ): JSX.Element => (
{ connectionRef.current.closing = true onDetach() diff --git a/packages/app/tests/docker-git/actions-projects.test.ts b/packages/app/tests/docker-git/actions-projects.test.ts index fa56a9f7..2af419d5 100644 --- a/packages/app/tests/docker-git/actions-projects.test.ts +++ b/packages/app/tests/docker-git/actions-projects.test.ts @@ -1,15 +1,19 @@ import { describe, expect, it } from "@effect/vitest" import { Effect } from "effect" -import { beforeEach, vi } from "vitest" +import { afterEach, beforeEach, vi } from "vitest" -import { connectProjectById } from "../../src/web/actions-projects.js" +import { applyProjectById, connectProjectById, runApplyAllProjects } from "../../src/web/actions-projects.js" import type { ProjectDetails, TerminalSession } from "../../src/web/api.js" import { makeBrowserActionContext, waitForAssertion } from "./browser-action-context-fixture.js" +const applyAllProjectsMock = vi.hoisted(() => vi.fn()) +const applyProjectMock = vi.hoisted(() => vi.fn()) const createProjectTerminalSessionMock = vi.hoisted(() => vi.fn()) const eventStreamCloseMock = vi.hoisted(() => vi.fn()) vi.mock("../../src/web/api.js", () => ({ + applyAllProjects: applyAllProjectsMock, + applyProject: applyProjectMock, createProjectTerminalSession: createProjectTerminalSessionMock, deleteProject: vi.fn(), downAllProjects: vi.fn(), @@ -78,8 +82,15 @@ const session: TerminalSession = { describe("web project actions", () => { beforeEach(() => { + applyAllProjectsMock.mockReset() + applyProjectMock.mockReset() createProjectTerminalSessionMock.mockReset() eventStreamCloseMock.mockReset() + vi.unstubAllGlobals() + }) + + afterEach(() => { + vi.unstubAllGlobals() }) it.effect("adds a new SSH terminal session instead of replacing terminal state", () => @@ -121,4 +132,38 @@ describe("web project actions", () => { "Project is ready. SSH terminal is connecting for octocat/hello-world." ) })) + + it.effect("applies a selected project through the project apply endpoint", () => + Effect.gen(function*(_) { + applyProjectMock.mockImplementation(() => Effect.succeed(project)) + const { context, reloadDashboard, setMessage } = makeBrowserActionContext() + + applyProjectById("project-1", context) + + yield* _(waitForAssertion(() => { + expect(applyProjectMock).toHaveBeenCalledWith("project-1") + })) + + expect(context.setSelectedProjectId).toHaveBeenCalledWith("project-1") + expect(context.setSelectedProject).toHaveBeenCalledWith(project) + expect(reloadDashboard).toHaveBeenCalledTimes(1) + expect(setMessage).toHaveBeenLastCalledWith("Applied octocat/hello-world.") + })) + + it.effect("confirms and applies all projects", () => + Effect.gen(function*(_) { + const confirmMock = vi.fn(() => true) + vi.stubGlobal("confirm", confirmMock) + applyAllProjectsMock.mockImplementation(() => Effect.void) + const { context, reloadDashboard, setMessage } = makeBrowserActionContext() + + runApplyAllProjects(context) + + yield* _(waitForAssertion(() => { + expect(applyAllProjectsMock).toHaveBeenCalledWith(false) + })) + + expect(reloadDashboard).toHaveBeenCalledTimes(1) + expect(setMessage).toHaveBeenLastCalledWith("Applied docker-git config to all projects.") + })) }) diff --git a/packages/lib/src/index.ts b/packages/lib/src/index.ts index f4193a1b..cead0335 100644 --- a/packages/lib/src/index.ts +++ b/packages/lib/src/index.ts @@ -10,6 +10,7 @@ export * from "./shell/docker.js" export * from "./shell/errors.js" export * from "./shell/files.js" export * from "./usecases/actions.js" +export * from "./usecases/apply.js" export * from "./usecases/auth.js" export * from "./usecases/errors.js" export * from "./usecases/menu-helpers.js" diff --git a/scripts/e2e/clone-cache.sh b/scripts/e2e/clone-cache.sh index 12989337..873e7402 100755 --- a/scripts/e2e/clone-cache.sh +++ b/scripts/e2e/clone-cache.sh @@ -14,6 +14,10 @@ chmod 0777 "$ROOT" mkdir -p "$ROOT/e2e" chmod 0777 "$ROOT/e2e" KEEP="${KEEP:-0}" +# Cold controller and project image builds can be slow on GitHub-hosted runners, +# but the clone command should still fail before the workflow-level timeout. +CLONE_COMMAND_TIMEOUT="${DOCKER_GIT_E2E_CLONE_CACHE_TIMEOUT:-1800s}" +FAILURE_DUMPED=0 dg_ensure_docker "$ROOT/.e2e-bin" dg_prepare_docker_git_cli "$REPO_ROOT" "$ROOT/.e2e-bin" @@ -28,9 +32,13 @@ MIRROR_PREFIX="/home/dev/.docker-git/.cache/git-mirrors" ACTIVE_OUT_DIR="" ACTIVE_CONTAINER="" +ACTIVE_CLONE_LOG="" fail() { echo "e2e/clone-cache: $*" >&2 + if [[ "$FAILURE_DUMPED" == "0" ]]; then + on_error "fail" + fi exit 1 } @@ -44,11 +52,19 @@ reset_shared_clone_cache_volume() { on_error() { local line="$1" + if [[ "$FAILURE_DUMPED" == "1" ]]; then + return + fi + FAILURE_DUMPED=1 echo "e2e/clone-cache: failed at line $line" >&2 docker ps -a --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' | head -n 80 || true if [[ -n "$ACTIVE_CONTAINER" ]]; then docker logs "$ACTIVE_CONTAINER" --tail 200 || true fi + if [[ -n "$ACTIVE_CLONE_LOG" ]] && [[ -f "$ACTIVE_CLONE_LOG" ]]; then + echo "--- host clone log ---" >&2 + cat "$ACTIVE_CLONE_LOG" >&2 || true + fi if [[ -n "$ACTIVE_OUT_DIR" ]] && [[ -f "$ACTIVE_OUT_DIR/docker-compose.yml" ]]; then (cd "$ACTIVE_OUT_DIR" && docker compose ps) || true (cd "$ACTIVE_OUT_DIR" && docker compose logs --no-color --tail 200) || true @@ -61,6 +77,7 @@ cleanup_active_case() { fi ACTIVE_OUT_DIR="" ACTIVE_CONTAINER="" + ACTIVE_CLONE_LOG="" } cleanup() { @@ -75,6 +92,8 @@ cleanup() { trap 'on_error $LINENO' ERR trap cleanup EXIT +command -v timeout >/dev/null 2>&1 || fail "missing 'timeout' command" + wait_for_clone_completion() { local container="$1" local attempts=120 @@ -109,6 +128,7 @@ run_clone_case() { local volume_name="dg-e2e-cache-${case_name}-${RUN_ID}-home" local ssh_port="$(( (RANDOM % 1000) + 22000 ))" local log_path="$ROOT/clone-cache-${case_name}.log" + local host_log_path="$ROOT/clone-cache-${case_name}-host.log" mkdir -p "$out_dir/.orch/env" chmod 0777 "$out_dir" "$out_dir/.orch" "$out_dir/.orch/env" @@ -120,10 +140,12 @@ EOF_ENV ACTIVE_OUT_DIR="$out_dir" ACTIVE_CONTAINER="$container_name" + ACTIVE_CLONE_LOG="$host_log_path" + set +e ( cd "$REPO_ROOT" - dg_run_docker_git "$REPO_ROOT" clone "$REPO_URL" \ + timeout "$CLONE_COMMAND_TIMEOUT" bun packages/app/dist/src/docker-git/main.js clone "$REPO_URL" \ --force \ --gh-skip \ --no-ssh \ @@ -133,7 +155,15 @@ EOF_ENV --container-name "$container_name" \ --service-name "$service_name" \ --volume-name "$volume_name" - ) + ) >"$host_log_path" 2>&1 + local clone_exit=$? + set -e + if [[ "$clone_exit" -eq 124 ]]; then + fail "clone command timed out after $CLONE_COMMAND_TIMEOUT for case: $case_name" + fi + if [[ "$clone_exit" -ne 0 ]]; then + fail "clone command failed with exit code $clone_exit for case: $case_name" + fi wait_for_clone_completion "$container_name" docker logs "$container_name" > "$log_path" 2>&1 || true diff --git a/scripts/e2e/runtime-volumes-ssh.sh b/scripts/e2e/runtime-volumes-ssh.sh index 11e0fa3e..9d7c983d 100755 --- a/scripts/e2e/runtime-volumes-ssh.sh +++ b/scripts/e2e/runtime-volumes-ssh.sh @@ -33,15 +33,26 @@ SSH_KEY="$ROOT/dev_ssh_key" SSH_PUB_KEY="$ROOT/dev_ssh_key.pub" CLONE_LOG="$ROOT/clone.log" SSH_LOG="$ROOT/runtime-volumes-ssh.log" +RUN_SCRIPT="$ROOT/run-runtime-volumes-ssh-clone.sh" +# Cold controller and project image builds can be slow on GitHub-hosted runners. +RUNTIME_CLONE_TIMEOUT="${DOCKER_GIT_E2E_RUNTIME_CLONE_TIMEOUT:-3300s}" HELPER_IMAGE="" +FAILURE_DUMPED=0 fail() { echo "e2e/runtime-volumes-ssh: $*" >&2 + if [[ "$FAILURE_DUMPED" == "0" ]]; then + on_error "fail" + fi exit 1 } on_error() { local line="$1" + if [[ "$FAILURE_DUMPED" == "1" ]]; then + return + fi + FAILURE_DUMPED=1 echo "e2e/runtime-volumes-ssh: failed at line $line" >&2 docker ps -a --format 'table {{.Names}}\t{{.Status}}\t{{.Ports}}' | head -n 80 || true if docker ps -a --format '{{.Names}}' | grep -qx "$CONTAINER_NAME" 2>/dev/null; then @@ -131,19 +142,36 @@ CODEX_AUTO_UPDATE=0 CODEX_SHARE_AUTH=1 EOF_ENV -( - cd "$REPO_ROOT" - dg_run_docker_git "$REPO_ROOT" clone "$REPO_URL" \ - --force \ - --gh-skip \ - --no-ssh \ - --authorized-keys "$ROOT/authorized_keys" \ - --ssh-port "$SSH_PORT" \ - --out-dir "$OUT_DIR_REL" \ - --container-name "$CONTAINER_NAME" \ - --service-name "$SERVICE_NAME" \ - --volume-name "$VOLUME_NAME" -) >"$CLONE_LOG" 2>&1 +cat > "$RUN_SCRIPT" <<'EOF_RUN' +#!/usr/bin/env bash +set -euo pipefail + +cd "$REPO_ROOT" +bun packages/app/dist/src/docker-git/main.js clone "$REPO_URL" \ + --force \ + --gh-skip \ + --no-ssh \ + --authorized-keys "$ROOT/authorized_keys" \ + --ssh-port "$SSH_PORT" \ + --out-dir "$OUT_DIR_REL" \ + --container-name "$CONTAINER_NAME" \ + --service-name "$SERVICE_NAME" \ + --volume-name "$VOLUME_NAME" +EOF_RUN +chmod +x "$RUN_SCRIPT" + +export REPO_ROOT REPO_URL ROOT SSH_PORT OUT_DIR_REL CONTAINER_NAME SERVICE_NAME VOLUME_NAME + +set +e +timeout "$RUNTIME_CLONE_TIMEOUT" "$RUN_SCRIPT" >"$CLONE_LOG" 2>&1 +clone_exit=$? +set -e +if [[ "$clone_exit" -eq 124 ]]; then + fail "clone command timed out after $RUNTIME_CLONE_TIMEOUT" +fi +if [[ "$clone_exit" -ne 0 ]]; then + fail "clone command failed with exit code $clone_exit" +fi grep -Fq -- "Project created: octocat/hello-world" "$CLONE_LOG" \ || fail "expected clone log to confirm project creation"