diff --git a/.github/workflows/portable-profile-e2e.yaml b/.github/workflows/portable-profile-e2e.yaml index 98c5471c0de..7c8d0599d70 100644 --- a/.github/workflows/portable-profile-e2e.yaml +++ b/.github/workflows/portable-profile-e2e.yaml @@ -13,10 +13,13 @@ on: - ".github/workflows/portable-profile-e2e.yaml" - "install.sh" - "scripts/install.sh" + - "scripts/install-openshell.sh" - "src/lib/onboard/**" - "src/lib/domain/sandbox/image-tag.ts" - "src/lib/sandbox/build-context.ts" + - "test/e2e/live/portable-profile-gateway-proof.ts" - "test/e2e/live/portable-profile-rootless-linux.test.ts" + - "tools/e2e/check-semantic-phases.mts" push: branches: - main @@ -24,10 +27,13 @@ on: - ".github/workflows/portable-profile-e2e.yaml" - "install.sh" - "scripts/install.sh" + - "scripts/install-openshell.sh" - "src/lib/onboard/**" - "src/lib/domain/sandbox/image-tag.ts" - "src/lib/sandbox/build-context.ts" + - "test/e2e/live/portable-profile-gateway-proof.ts" - "test/e2e/live/portable-profile-rootless-linux.test.ts" + - "tools/e2e/check-semantic-phases.mts" permissions: contents: read @@ -58,6 +64,11 @@ jobs: - name: Build shared policy boundary run: npm run build:policy-boundary + - name: Install pinned OpenShell + run: | + env -u GH_TOKEN -u GITHUB_TOKEN NEMOCLAW_NON_INTERACTIVE=1 bash scripts/install-openshell.sh + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + - name: Provision restricted rootless Linux runtime shell: bash run: | diff --git a/src/lib/onboard/docker-driver-gateway-config.ts b/src/lib/onboard/docker-driver-gateway-config.ts index befc6b659dd..9a1ea6980a1 100644 --- a/src/lib/onboard/docker-driver-gateway-config.ts +++ b/src/lib/onboard/docker-driver-gateway-config.ts @@ -81,7 +81,10 @@ export function buildDockerDriverGatewayConfigToml( ["host_gateway_ip", driver === "podman" ? PORTABLE_HOST_GATEWAY_IP : undefined], ["network_name", gatewayEnv.OPENSHELL_DOCKER_NETWORK_NAME], ["supervisor_image", gatewayEnv.OPENSHELL_DOCKER_SUPERVISOR_IMAGE], - ["supervisor_bin", sandboxBin ?? undefined], + // OpenShell 0.0.85 accepts supervisor_bin only for the Docker driver. + // The Podman schema rejects the entire driver table when this Docker-only + // field is present, so portable onboarding must rely on supervisor_image. + ["supervisor_bin", driver === "docker" ? (sandboxBin ?? undefined) : undefined], ["guest_tls_ca", localTlsDir ? path.join(localTlsDir, "ca.crt") : undefined], ["guest_tls_cert", localTlsDir ? path.join(localTlsDir, "client", "tls.crt") : undefined], ["guest_tls_key", localTlsDir ? path.join(localTlsDir, "client", "tls.key") : undefined], diff --git a/src/lib/onboard/docker-driver-gateway-env.test.ts b/src/lib/onboard/docker-driver-gateway-env.test.ts index 8d5e948e3d6..7763ab552f5 100644 --- a/src/lib/onboard/docker-driver-gateway-env.test.ts +++ b/src/lib/onboard/docker-driver-gateway-env.test.ts @@ -88,6 +88,7 @@ describe("buildDockerDriverGatewayEnv", () => { expect(toml).toContain('compute_drivers = ["podman"]'); expect(toml).toContain("[openshell.drivers.podman]"); expect(toml).toContain('host_gateway_ip = "169.254.1.2"'); + expect(toml).not.toContain("supervisor_bin"); } finally { vi.unstubAllEnvs(); fs.rmSync(stateDir, { recursive: true, force: true }); diff --git a/test/e2e/live/portable-profile-gateway-proof.ts b/test/e2e/live/portable-profile-gateway-proof.ts new file mode 100644 index 00000000000..4c2343ac950 --- /dev/null +++ b/test/e2e/live/portable-profile-gateway-proof.ts @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; + +import { spawnObservedChild } from "../fixtures/observed-child-process.ts"; +import type { TestProgress } from "../fixtures/progress.ts"; +import { stripAnsi } from "./json-envelope.ts"; + +/** Verify that the pinned gateway accepts the generated rootless Podman configuration. */ +export async function verifyPinnedPodmanGatewayStarts( + gatewayBin: string, + gatewayEnv: Record, + progress: TestProgress, +): Promise { + const child = spawnObservedChild(gatewayBin, [], { + activityLabel: "command: pinned OpenShell Podman gateway", + progress, + spawn: { + env: { ...process.env, ...gatewayEnv }, + stdio: ["ignore", "pipe", "pipe"], + }, + }); + let output = ""; + child.stdout?.on("data", (chunk) => { + output += String(chunk); + }); + child.stderr?.on("data", (chunk) => { + output += String(chunk); + }); + try { + const deadline = Date.now() + 15_000; + let driverSeenAt = 0; + while (Date.now() < deadline) { + const plainOutput = stripAnsi(output); + if (/configuration error|invalid \[openshell\.drivers\.podman\] table/i.test(plainOutput)) { + assert.fail(`Pinned OpenShell rejected the generated Podman configuration:\n${output}`); + } + if (child.exitCode !== null) { + assert.fail( + `Pinned OpenShell Podman gateway exited with ${String(child.exitCode)}:\n${output}`, + ); + } + if (/Using compute driver\s+driver=podman/.test(plainOutput)) { + if (driverSeenAt === 0) driverSeenAt = Date.now(); + if (Date.now() - driverSeenAt >= 2_000) return; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + assert.fail( + `Pinned OpenShell did not report driver=podman and remain running for two seconds:\n${output}`, + ); + } finally { + child.kill("SIGTERM"); + } +} diff --git a/test/e2e/live/portable-profile-rootless-linux.test.ts b/test/e2e/live/portable-profile-rootless-linux.test.ts index 6f4da3f4e8a..c4a31928c5a 100644 --- a/test/e2e/live/portable-profile-rootless-linux.test.ts +++ b/test/e2e/live/portable-profile-rootless-linux.test.ts @@ -9,16 +9,24 @@ import os from "node:os"; import path from "node:path"; import * as importedGatewayEnv from "../../../src/lib/onboard/docker-driver-gateway-env.ts"; +import * as importedGatewayLocalTls from "../../../src/lib/onboard/docker-driver-gateway-local-tls.ts"; import * as importedPortableHostPreparation from "../../../src/lib/onboard/experimental/portable-host-preparation.ts"; import * as importedSandboxPrebuild from "../../../src/lib/onboard/sandbox-prebuild.ts"; import * as importedBuildContext from "../../../src/lib/sandbox/build-context.ts"; import { test } from "../fixtures/e2e-test.ts"; +import type { TestProgress } from "../fixtures/progress.ts"; +import { verifyPinnedPodmanGatewayStarts } from "./portable-profile-gateway-proof.ts"; const gatewayEnvModule = ( "default" in importedGatewayEnv && importedGatewayEnv.default ? importedGatewayEnv.default : importedGatewayEnv ) as typeof import("../../../src/lib/onboard/docker-driver-gateway-env.ts"); +const gatewayLocalTlsModule = ( + "default" in importedGatewayLocalTls && importedGatewayLocalTls.default + ? importedGatewayLocalTls.default + : importedGatewayLocalTls +) as typeof import("../../../src/lib/onboard/docker-driver-gateway-local-tls.ts"); const portableHostPreparationModule = ( "default" in importedPortableHostPreparation && importedPortableHostPreparation.default ? importedPortableHostPreparation.default @@ -36,6 +44,7 @@ const buildContextModule = ( ) as typeof import("../../../src/lib/sandbox/build-context.ts"); const { buildDockerDriverGatewayEnv } = gatewayEnvModule; +const { ensureDockerDriverGatewayLocalTlsBundle } = gatewayLocalTlsModule; const { preparePortableExperimentalHost } = portableHostPreparationModule; const { prebuildSandboxImageIfEligible } = sandboxPrebuildModule; const { SANDBOX_BUILD_CONTEXT_PREFIX } = buildContextModule; @@ -46,6 +55,7 @@ const PORTABLE_PROFILE_E2E_PHASES = [ "select the Podman-reported runtime socket", "prepare the rootless container runtime", "build and publish the sandbox image", + "start the pinned Podman gateway", "verify the fixed host route", "record portable environment completion", ] as const; @@ -141,7 +151,7 @@ function selectInstallerPodmanRuntime(repoRoot: string): string { return run("bash", ["-c", script]); } -async function main(progress: { phase: (phase: string) => void }): Promise { +async function main(progress: TestProgress): Promise { assert.equal(process.platform, "linux", "portable profile E2E requires Linux"); assert.notEqual(process.getuid?.(), 0, "portable profile E2E must run without root privileges"); @@ -222,21 +232,32 @@ async function main(progress: { phase: (phase: string) => void }): Promise "nemoclaw-portable-registry", ]); - progress.phase("verify the fixed host route"); + const gatewayBin = run("bash", ["-lc", "command -v openshell-gateway"]); + const sandboxBin = run("bash", ["-lc", "command -v openshell-sandbox"]); const gatewayEnv = buildDockerDriverGatewayEnv({ platform: "linux", - gatewayPort: 5000, + gatewayPort: 8080, stateDir, getDockerSupervisorImage: () => "supervisor:e2e-not-launched", - resolveSandboxBin: () => null, + resolveSandboxBin: () => sandboxBin, }); assert.equal(gatewayEnv.OPENSHELL_DRIVERS, "podman"); assert.equal(gatewayEnv.OPENSHELL_BIND_ADDRESS, "0.0.0.0"); - assert.equal(gatewayEnv.OPENSHELL_GRPC_ENDPOINT, "https://169.254.1.2:5000"); + assert.equal(gatewayEnv.OPENSHELL_GRPC_ENDPOINT, "https://169.254.1.2:8080"); assert.match( fs.readFileSync(gatewayEnv.OPENSHELL_GATEWAY_CONFIG, "utf-8"), /host_gateway_ip = "169\.254\.1\.2"/, ); + assert.doesNotMatch( + fs.readFileSync(gatewayEnv.OPENSHELL_GATEWAY_CONFIG, "utf-8"), + /supervisor_bin/, + ); + + progress.phase("start the pinned Podman gateway"); + ensureDockerDriverGatewayLocalTlsBundle({ gatewayBin, stateDir }); + await verifyPinnedPodmanGatewayStarts(gatewayBin, gatewayEnv, progress); + + progress.phase("verify the fixed host route"); const routeProof = [ "exec 3<>/dev/tcp/169.254.1.2/5000", diff --git a/test/e2e/support/e2e-workflow.test.ts b/test/e2e/support/e2e-workflow.test.ts index 1a30d22c8b0..f8091eb051b 100644 --- a/test/e2e/support/e2e-workflow.test.ts +++ b/test/e2e/support/e2e-workflow.test.ts @@ -715,8 +715,26 @@ describe("e2e workflow boundary", () => { jobs: Record; if?: string }>; }; const workflowJobs = new Set(Object.keys(workflow.jobs)); + const portableWorkflow = YAML.parse( + fs.readFileSync( + path.join(process.cwd(), ".github", "workflows", "portable-profile-e2e.yaml"), + "utf8", + ), + ) as { + on?: { pull_request?: { paths?: string[] }; push?: { paths?: string[] } }; + }; + const portableProofInputs = [ + "scripts/install-openshell.sh", + "test/e2e/live/portable-profile-gateway-proof.ts", + "test/e2e/live/portable-profile-rootless-linux.test.ts", + "tools/e2e/check-semantic-phases.mts", + ]; expect(validateFreeStandingWorkflowInventory()).toEqual([]); + expect(portableWorkflow.on?.pull_request?.paths).toEqual( + expect.arrayContaining(portableProofInputs), + ); + expect(portableWorkflow.on?.push?.paths).toEqual(expect.arrayContaining(portableProofInputs)); expect(inventory.allowedJobs).not.toHaveLength(0); expect(inventory.targetToJob.size).toBeGreaterThan(0); expect(inventory.workflowJobs.every((job) => workflowJobs.has(job))).toBe(true); diff --git a/tools/e2e/check-semantic-phases.mts b/tools/e2e/check-semantic-phases.mts index 5b8d3fb6ab2..8624e04d024 100644 --- a/tools/e2e/check-semantic-phases.mts +++ b/tools/e2e/check-semantic-phases.mts @@ -387,6 +387,10 @@ const OBSERVED_CHILD_PROGRESS_POLICIES = new Map