Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 41 additions & 5 deletions .github/workflows/e2e.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2184,17 +2184,30 @@ jobs:
run: bash .github/scripts/docker-auth-cleanup.sh

network-policy:
name: Network policy (${{ matrix.scenario }})
needs: generate-matrix
if: ${{ (github.event_name != 'workflow_dispatch' || (inputs.jobs == '' && inputs.targets == '')) || contains(format(',{0},', inputs.jobs), ',network-policy,') || contains(format(',{0},', inputs.targets), ',network-policy,') }}
runs-on: ubuntu-latest
timeout-minutes: 90
strategy:
fail-fast: false
matrix:
include:
- scenario: live-probes
selector: "^network-policy:.+probes$"
sandbox: e2e-net-policy-live-probes
- scenario: zero-presets
selector: "^network-policy:.+presets$"
sandbox: e2e-net-policy-zero-presets
env:
E2E_JOB: "1"
E2E_TARGET_ID: "network-policy"
E2E_ARTIFACT_DIR: ${{ github.workspace }}/e2e-artifacts/live/network-policy
E2E_ARTIFACT_DIR: ${{ github.workspace }}/e2e-artifacts/live/network-policy/${{ matrix.scenario }}
NEMOCLAW_CLI_BIN: ${{ github.workspace }}/bin/nemoclaw.js
NEMOCLAW_E2E_SHARD: ${{ matrix.scenario }}
NEMOCLAW_RUN_LIVE_E2E: "1"
NEMOCLAW_E2E_USE_HOSTED_INFERENCE: "1"
NEMOCLAW_SANDBOX_NAME: ${{ matrix.sandbox }}
# Raw OpenShell sandbox commands in the migrated live test must target
# the gateway registered by NemoClaw onboarding even when OpenShell has
# no active gateway selected on the runner.
Expand Down Expand Up @@ -2236,27 +2249,44 @@ jobs:
NVIDIA_INFERENCE_API_KEY: ${{ secrets.NVIDIA_INFERENCE_API_KEY }}
run: |
set -euo pipefail
npx tsx tools/e2e/live-vitest-invocation.mts run --test-path test/e2e/live/network-policy.test.ts
npx tsx tools/e2e/live-vitest-invocation.mts run --test-path test/e2e/live/network-policy.test.ts \
--selector "${{ matrix.selector }}"

- name: Upload network-policy artifacts
if: always()
uses: NVIDIA/NemoClaw/.github/actions/upload-e2e-artifacts@7768e15eb90d3ee2d33432f481dfe8747e4f6d57
with:
name: e2e-network-policy-${{ matrix.scenario }}
path: e2e-artifacts/live/network-policy/${{ matrix.scenario }}/

- name: Clean up Docker auth
if: always()
shell: bash
run: bash .github/scripts/docker-auth-cleanup.sh

common-egress-agent:
name: Common egress agent (${{ matrix.scenario }})
needs: generate-matrix
if: ${{ (github.event_name != 'workflow_dispatch' || (inputs.jobs == '' && inputs.targets == '')) || contains(format(',{0},', inputs.jobs), ',common-egress-agent,') || contains(format(',{0},', inputs.targets), ',common-egress-agent,') }}
runs-on: ${{ fromJSON(needs.generate-matrix.outputs.runner_routing)['common-egress-agent'] }}
timeout-minutes: 120
timeout-minutes: 60
strategy:
fail-fast: false
max-parallel: 2
matrix:
include:
- scenario: openclaw-balanced-weather
selector: "^common-egress.+C1.+$"
- scenario: openclaw-open-reference
selector: "^common-egress.+C2.+$"
- scenario: hermes-open-reference
selector: "^common-egress.+C3.+$"
env:
E2E_JOB: "1"
E2E_TARGET_ID: "common-egress-agent"
E2E_ARTIFACT_DIR: ${{ github.workspace }}/e2e-artifacts/live/common-egress-agent
E2E_ARTIFACT_DIR: ${{ github.workspace }}/e2e-artifacts/live/common-egress-agent/${{ matrix.scenario }}
NEMOCLAW_CLI_BIN: ${{ github.workspace }}/bin/nemoclaw.js
NEMOCLAW_E2E_SHARD: ${{ matrix.scenario }}
NEMOCLAW_RUN_LIVE_E2E: "1"
NEMOCLAW_E2E_USE_HOSTED_INFERENCE: "1"
NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1"
Expand Down Expand Up @@ -2311,7 +2341,8 @@ jobs:
export OPENSHELL_BIN
echo "Using OPENSHELL_BIN=$OPENSHELL_BIN"
"$OPENSHELL_BIN" --version
npx tsx tools/e2e/live-vitest-invocation.mts run --test-path test/e2e/live/common-egress-agent.test.ts
npx tsx tools/e2e/live-vitest-invocation.mts run --test-path test/e2e/live/common-egress-agent.test.ts \
--selector "${{ matrix.selector }}"

- name: Finalize runner comparison telemetry
if: ${{ always() && github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && inputs.checkout_sha == '' }}
Expand All @@ -2322,6 +2353,9 @@ jobs:
- name: Upload common-egress agent artifacts
if: always()
uses: NVIDIA/NemoClaw/.github/actions/upload-e2e-artifacts@7768e15eb90d3ee2d33432f481dfe8747e4f6d57
with:
name: e2e-common-egress-agent-${{ matrix.scenario }}
path: e2e-artifacts/live/common-egress-agent/${{ matrix.scenario }}/

- name: Clean up Docker auth
if: always()
Expand Down Expand Up @@ -3352,6 +3386,8 @@ jobs:

- name: Prepare E2E workspace
uses: NVIDIA/NemoClaw/.github/actions/prepare-e2e@50281ee84c4a6fc759da95ea28fc0b7d9c378a28
with:
build-cli: "false"

- name: Initialize runner comparison telemetry
if: ${{ github.repository == 'NVIDIA/NemoClaw' && github.ref == 'refs/heads/main' && inputs.checkout_sha == '' && matrix.agent == 'hermes' }}
Expand Down
10 changes: 6 additions & 4 deletions test/e2e/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,10 +139,11 @@ graph as the live targets:
### Runner comparison telemetry

Trusted `main` runs without an alternate checkout SHA record runner-comparison
telemetry for the #7145 contract: 12 routed workflow lane identities / 13
telemetry for the #7145 contract: 12 routed workflow lane identities / 15
concrete job executions.

- `common-egress-agent`
- `common-egress-agent` with the `openclaw-balanced-weather`,
`openclaw-open-reference`, and `hermes-open-reference` shards
- `rebuild-hermes`
- `rebuild-hermes-stale-base`
- `mcp-bridge` with the `hermes` shard
Expand All @@ -155,8 +156,9 @@ concrete job executions.
- `hermes-shields-config`
- `security-posture` with the `hermes` shard

The extra execution comes from `hermes-inference-switch`, which runs both
listed modes. The OpenClaw matrix entries for `mcp-bridge`,
The three extra executions come from `common-egress-agent`, which runs three
scenario shards, and `hermes-inference-switch`, which runs both listed modes.
The OpenClaw matrix entries for `mcp-bridge`,
`channels-stop-start`, and `security-posture` are not instrumented.

Each execution writes two best-effort numeric samples to
Expand Down
19 changes: 19 additions & 0 deletions test/e2e/support/e2e-cleanup-resources.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,25 @@ describe("cleanup resources", () => {
expect(calls).toBe(1);
});

it("registers sandbox cleanup before installer side effects (#7146)", () => {
for (const fileName of ["full-e2e.test.ts", "hermes-e2e.test.ts"]) {
const source = fs.readFileSync(
path.resolve(import.meta.dirname, "..", "live", fileName),
"utf8",
);
const cleanupRegistration = source.indexOf(".trackSandbox(host, SANDBOX_NAME");
const installerStart = source.indexOf('host.command("bash", ["install.sh"');

expect(
cleanupRegistration,
`${fileName} must register sandbox cleanup`,
).toBeGreaterThanOrEqual(0);
expect(installerStart, `${fileName} must invoke install.sh`).toBeGreaterThan(
cleanupRegistration,
);
}
});

it("continues typed cleanup after a resource failure", async () => {
const calls: string[] = [];
const host: CleanupHost = {
Expand Down
91 changes: 91 additions & 0 deletions test/e2e/support/e2e-workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,97 @@ describe("e2e workflow boundary", () => {
);
});

it("keeps network-policy scenarios isolated with cleanup reserve", () => {
const workflow = readWorkflow() as {
jobs: Record<
string,
{
env: Record<string, unknown>;
steps: Array<{ name?: string; run?: string; with?: Record<string, unknown> }>;
strategy: {
"fail-fast": boolean;
matrix: { include: Array<Record<string, string>> };
};
"timeout-minutes": number;
}
>;
};
const job = workflow.jobs["network-policy"]!;
const source = fs.readFileSync("test/e2e/live/network-policy.test.ts", "utf8");
expect(source).toContain("const TEST_TIMEOUT_MS = 65 * 60_000;");

job["timeout-minutes"] = 65;
job.strategy["fail-fast"] = true;
job.strategy.matrix.include.pop();
job.env.E2E_ARTIFACT_DIR = "${{ github.workspace }}/e2e-artifacts/live/network-policy";
delete job.env.NEMOCLAW_E2E_SHARD;
delete job.env.NEMOCLAW_SANDBOX_NAME;
const run = job.steps.find((step) => step.name === "Run network-policy live test")!;
run.run = run.run!.replace('--selector "${{ matrix.selector }}"', "--selector all");
const upload = job.steps.find((step) => step.name === "Upload network-policy artifacts")!;
delete upload.with;

expect(validateE2eWorkflow(workflow)).toEqual(
expect.arrayContaining([
"network-policy scenario jobs must keep the 90 minute timeout",
"network-policy scenario matrix must disable fail-fast",
"network-policy job must keep the two isolated scenario shards",
"network-policy job must isolate artifacts by matrix.scenario",
"network-policy job must bind NEMOCLAW_E2E_SHARD to matrix.scenario",
"network-policy job must bind its sandbox name to matrix.sandbox",
`step 'Run network-policy live test' run script must include --selector "\${{ matrix.selector }}"`,
"network-policy upload-e2e-artifacts invocation must not override its contract",
"network-policy upload-e2e-artifacts must preserve its explicit name/path contract",
]),
);
});

it("keeps common-egress scenarios isolated with bounded concurrency and cleanup reserve", () => {
const workflow = readWorkflow() as {
jobs: Record<
string,
{
env: Record<string, unknown>;
steps: Array<{ name?: string; run?: string; with?: Record<string, unknown> }>;
strategy: {
"fail-fast": boolean;
"max-parallel": number;
matrix: { include: Array<Record<string, string>> };
};
"timeout-minutes": number;
}
>;
};
const job = workflow.jobs["common-egress-agent"]!;
const source = fs.readFileSync("test/e2e/live/common-egress-agent.test.ts", "utf8");
expect(source).toContain("const TEST_TIMEOUT_MS = 40 * 60_000;");

job["timeout-minutes"] = 40;
job.strategy["fail-fast"] = true;
job.strategy["max-parallel"] = 3;
job.strategy.matrix.include.pop();
job.env.E2E_ARTIFACT_DIR = "${{ github.workspace }}/e2e-artifacts/live/common-egress-agent";
delete job.env.NEMOCLAW_E2E_SHARD;
const run = job.steps.find((step) => step.name === "Run common-egress agent live test")!;
run.run = run.run!.replace('--selector "${{ matrix.selector }}"', "--selector all");
const upload = job.steps.find((step) => step.name === "Upload common-egress agent artifacts")!;
delete upload.with;

expect(validateE2eWorkflow(workflow)).toEqual(
expect.arrayContaining([
"common-egress-agent scenario jobs must keep the 60 minute timeout",
"common-egress-agent scenario matrix must disable fail-fast",
"common-egress-agent scenario matrix must cap concurrency at two",
"common-egress-agent job must keep the three isolated scenario shards",
"common-egress-agent job must isolate artifacts by matrix.scenario",
"common-egress-agent job must bind NEMOCLAW_E2E_SHARD to matrix.scenario",
`step 'Run common-egress agent live test' run script must include --selector "\${{ matrix.selector }}"`,
"common-egress-agent upload-e2e-artifacts invocation must not override its contract",
"common-egress-agent upload-e2e-artifacts must preserve its explicit name/path contract",
]),
);
});

it("binds typed-target evidence identity and upload to the live matrix entry", () => {
const workflow = readWorkflow() as {
jobs: Record<
Expand Down
14 changes: 14 additions & 0 deletions test/e2e/support/prepare-e2e-workflow-boundary.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,20 @@ describe("prepare-e2e workflow boundary", () => {
expect(validatePrepareE2eInvocations(readWorkflow())).toEqual([]);
});

it("keeps the installer-backed security posture matrix on the no-build bootstrap", () => {
const workflow = readWorkflow() as Workflow;
const securityPostureJob = workflow.jobs["security-posture"];
const prepare = securityPostureJob.steps!.find((step) => step.uses === PREPARE_E2E_ACTION)!;
delete prepare.with;

expect(validatePrepareE2eInvocations(workflow)).toEqual(
expect.arrayContaining([
"security-posture prepare-e2e must set build-cli to false",
"security-posture prepare-e2e invocation must not override its canonical contract",
]),
);
});

it("rejects action implementation drift", () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "prepare-e2e-action-"));
const actionPath = path.join(directory, "action.yaml");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ describe("upload-e2e-artifacts workflow boundary", () => {
defaultJob.env!.E2E_TARGET_ID = "not a selector id";

uploadStep(workflow.jobs["hermes-slack"]).with!.path = "e2e-artifacts/live/hermes-slack/";
uploadStep(workflow.jobs["network-policy"]).with!.name = "e2e-network-policy";
uploadStep(workflow.jobs["common-egress-agent"]).with!.name = "e2e-common-egress-agent";
uploadStep(workflow.jobs["gpu-e2e"]).if = "success()";
uploadStep(workflow.jobs["mcp-bridge"]).if = "always()";
uploadStep(workflow.jobs["openshell-gateway-auth-contract"]).if = "always()";
Expand All @@ -163,6 +165,8 @@ describe("upload-e2e-artifacts workflow boundary", () => {
"credential-migration upload-e2e-artifacts must use the action defaults",
"credential-migration default upload caller must declare a valid E2E_TARGET_ID",
"hermes-slack upload-e2e-artifacts must preserve its explicit name/path contract",
"network-policy upload-e2e-artifacts must preserve its explicit name/path contract",
"common-egress-agent upload-e2e-artifacts must preserve its explicit name/path contract",
"gpu-e2e upload-e2e-artifacts invocation must run with always()",
"mcp-bridge upload-e2e-artifacts invocation must remain gated by its reviewed pre-upload checks",
"openshell-gateway-auth-contract upload-e2e-artifacts invocation must remain gated by its reviewed pre-upload checks",
Expand Down
2 changes: 1 addition & 1 deletion test/pr-e2e-gate-signal-shards.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ describe("PR E2E signal shard policy", () => {
const broadPlan = buildRiskPlan({ headSha: HEAD_SHA, changedFiles: BROAD_FILES });
const broadShards = expectedSignalShards(riskPlanRequiredJobIds(broadPlan));
expect(Object.keys(broadShards)).toHaveLength(13);
expect(Object.values(broadShards).flat()).toHaveLength(15);
expect(Object.values(broadShards).flat()).toHaveLength(16);
expect(() => expectedSignalShards(["not-a-workflow-job"])).toThrow(/does not define/u);
});
});
1 change: 1 addition & 0 deletions tools/e2e/prepare-e2e-workflow-boundary.mts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const NO_BUILD_JOBS = new Set([
"generate-matrix",
"bootstrap-install-smoke",
"ollama-auth-proxy",
"security-posture",
"shields-config",
"snapshot-commands",
"spark-install",
Expand Down
14 changes: 14 additions & 0 deletions tools/e2e/upload-e2e-artifacts-workflow-boundary.mts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,20 @@ const EXPLICIT_UPLOAD_CONTRACTS = new Map<string, ExplicitUploadContract>([
path: "e2e-artifacts/live/hermes-inference-switch/${{ matrix.mode }}/",
},
],
[
"network-policy",
{
name: "e2e-network-policy-${{ matrix.scenario }}",
path: "e2e-artifacts/live/network-policy/${{ matrix.scenario }}/",
},
],
[
"common-egress-agent",
{
name: "e2e-common-egress-agent-${{ matrix.scenario }}",
path: "e2e-artifacts/live/common-egress-agent/${{ matrix.scenario }}/",
},
],
[
"hermes-gpu-startup",
{
Expand Down
Loading
Loading