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
17 changes: 14 additions & 3 deletions .github/workflows/portable-profile-e2e.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,19 @@

name: E2E / Portable Profile

run-name: "Portable profile rootless E2E for ${{ github.sha }}"
run-name: "Portable profile rootless E2E for ${{ github.event.pull_request.head.sha || github.sha }}"

on:
workflow_dispatch:
pull_request:
types: [opened, synchronize, reopened]
paths:
- ".github/workflows/portable-profile-e2e.yaml"
- "src/lib/onboard/experimental/portable-host-preparation.ts"
- "src/lib/onboard/experimental/portable-profile.ts"
- "src/lib/onboard/experimental/portable-retired-subnet-recovery.test.ts"
- "test/e2e/live/portable-profile-rootless-linux.test.ts"
- "test/e2e/support/portable-profile-rootless-runtime-workflow.test.ts"
push:
branches:
- main
Expand All @@ -32,20 +41,22 @@ permissions:
contents: read

concurrency:
group: portable-profile-e2e-${{ github.ref }}
group: portable-profile-e2e-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

jobs:
rootless-linux:
runs-on: ubuntu-26.04
timeout-minutes: 25
env:
E2E_SOURCE_REVISION: ${{ github.event.pull_request.head.sha || github.sha }}
PODMAN_APT_VERSION: "5.7.0+ds2-3build1"
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
ref: ${{ github.event.pull_request.head.sha || github.sha }}

- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
Expand Down Expand Up @@ -125,7 +136,7 @@ jobs:
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: portable-profile-e2e-artifacts
name: portable-profile-e2e-artifacts-${{ github.event.pull_request.head.sha || github.sha }}
path: e2e-artifacts/portable-profile/
include-hidden-files: false
if-no-files-found: ignore
Expand Down
5 changes: 5 additions & 0 deletions ci/source-shape-test-budget.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@
"test": "keeps live E2E on the accepted rootless runtime and local registry authority (#9006)",
"category": "compatibility"
},
{
"file": "test/e2e/support/portable-profile-rootless-runtime-workflow.test.ts",
"test": "selects exact-commit rootless evidence for Portable recovery changes (#9707)",
"category": "security"
},
{
"file": "test/growth-guardrails-workflow-boundary.test.ts",
"test": "runs the trusted Vitest guardrails against pull request data",
Expand Down
24 changes: 22 additions & 2 deletions docs/inference/set-up-openai-compatible-endpoint.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -132,10 +132,10 @@ curl -fsSL https://www.nvidia.com/nemoclaw.sh | NEMOCLAW_AGENT=langchain-deepage

</AgentOnly>

The portable profile creates the `openshell-docker` network on `169.254.1.0/24` and assigns `169.254.2.2/32` to the host loopback interface.
The portable profile creates the `openshell-docker` network on `10.87.0.0/24` and assigns `169.254.2.2/32` to the host loopback interface.
The address `169.254.2.2` is outside the Portable sandbox subnet and is the host-gateway address for `host.openshell.internal`.
It carries authenticated mTLS plus sandbox-JWT callbacks to OpenShell on port `8080`.
The managed local registry uses the distinct address `169.254.1.3` on port `5000`.
The managed local registry uses the distinct address `10.87.0.3` on port `5000`.
Keeping the host-gateway address outside the sandbox subnet prevents a Portable workload from receiving that address.

<Warning>
Expand All @@ -153,6 +153,26 @@ $$nemoclaw onboard --experimental-profile portable

Do not run the loopback deletion command when onboarding reports another interface or prefix.
Investigate and resolve the conflicting assignment before you rerun onboarding.

If Portable onboarding reports that `openshell-docker` still uses `169.254.1.0/24`, use only the commands in that error. Before it shows a command, NemoClaw verifies the current-user Podman socket, the complete retired network record, and every running or stopped container connected to that network. Each command includes the verified socket URL and the complete 64-character resource ID.

When no container is connected, onboarding prints one network command:

```bash
podman --url 'unix://<verified-current-user-socket>' network rm <64-character-network-id>
```

When the exact NemoClaw-managed registry is the only connected container, onboarding prints the registry commands before the network command. A stopped registry does not need the first command. Run each printed command in order, and continue only after it succeeds:

```bash
podman --url 'unix://<verified-current-user-socket>' container stop <64-character-registry-id>
podman --url 'unix://<verified-current-user-socket>' container rm <64-character-registry-id>
podman --url 'unix://<verified-current-user-socket>' network rm <64-character-network-id>
```

Do not change the socket or IDs, and do not add `--force` to any command. If inspection is inconclusive or any other container is connected, onboarding prints no removal command. Leave the network and containers unchanged. This recovery does not remove sandboxes, inference containers, or Hermes resources. Use the lifecycle that created each resource, then retry onboarding.

Rerun Portable onboarding once the network is gone.
</Warning>

The portable profile handles the descriptor as follows:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ function createRuntimeFixture() {
});
harness.state.networkId = NETWORK_ID;
harness.state.networkName = "openshell-docker";
harness.state.networkGatewayIp = "169.254.1.1";
harness.state.networkGatewayIp = "10.87.0.1";
harness.state.ollamaPsModels = [
{
name: "qwen3-vl:4b",
Expand Down Expand Up @@ -378,7 +378,7 @@ describe("Hermes Portable Ollama inference activation", () => {
model: "qwen3-vl:4b",
networkName: "openshell-docker",
networkId: NETWORK_ID,
networkGatewayIp: "169.254.1.1",
networkGatewayIp: "10.87.0.1",
networkListenerIp: PORTABLE_HOST_GATEWAY_IP,
gpuDevices: [GPU_DEVICE],
},
Expand Down
17 changes: 8 additions & 9 deletions src/lib/onboard/experimental/portable-host-preparation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,10 +153,9 @@ describe("preparePortableExperimentalHost", () => {
const portableSandboxAddresses = new BlockList();
portableSandboxAddresses.addSubnet(networkAddress!, Number(prefixText), "ipv4");

expect(PORTABLE_DOCKER_NETWORK_SUBNET).toBe("169.254.1.0/24");
expect(PORTABLE_REGISTRY_IP).toBe("169.254.1.3");
expect(PORTABLE_DOCKER_NETWORK_SUBNET).toBe("10.87.0.0/24");
expect(PORTABLE_REGISTRY_IP).toBe("10.87.0.3");
expect(PORTABLE_HOST_GATEWAY_IP).toBe("169.254.2.2");
expect(portableSandboxAddresses.check("169.254.1.2", "ipv4")).toBe(true);
expect(portableSandboxAddresses.check(PORTABLE_REGISTRY_IP, "ipv4")).toBe(true);
expect(portableSandboxAddresses.check(PORTABLE_HOST_GATEWAY_IP, "ipv4")).toBe(false);
expect(PORTABLE_HOST_GATEWAY_IP).not.toBe(PORTABLE_REGISTRY_IP);
Expand Down Expand Up @@ -645,7 +644,7 @@ describe("preparePortableExperimentalHost", () => {
.fn<(args: readonly string[], env: NodeJS.ProcessEnv) => SpawnResult>()
.mockReturnValueOnce(result())
.mockReturnValueOnce(result(0, JSON.stringify([{ Subnet: PORTABLE_DOCKER_NETWORK_SUBNET }])))
.mockReturnValueOnce(result(0, `1 true ${PORTABLE_REGISTRY_IP}`));
.mockReturnValueOnce(result(0, `1|true|${PORTABLE_REGISTRY_IP}`));
const ip = vi
.fn<(args: readonly string[], env: NodeJS.ProcessEnv) => SpawnResult>()
.mockReturnValueOnce(result(0, NO_RETIRED_GATEWAY_EVIDENCE))
Expand Down Expand Up @@ -743,7 +742,7 @@ describe("preparePortableExperimentalHost", () => {
.fn<(args: readonly string[], env: NodeJS.ProcessEnv) => SpawnResult>()
.mockReturnValueOnce(result())
.mockReturnValueOnce(result(0, JSON.stringify([{ Subnet: PORTABLE_DOCKER_NETWORK_SUBNET }])))
.mockReturnValueOnce(result(0, `1 true ${PORTABLE_REGISTRY_IP}`));
.mockReturnValueOnce(result(0, `1|true|${PORTABLE_REGISTRY_IP}`));

preparePortableExperimentalHost(
{ NEMOCLAW_EXPERIMENTAL_PROFILE: "portable" },
Expand All @@ -767,7 +766,7 @@ describe("preparePortableExperimentalHost", () => {
it.each<[SpawnResult, string]>([
[
result(0, JSON.stringify([{ Subnet: "10.88.0.0/16" }])),
"Refusing to reuse network 'openshell-docker' with unexpected subnet '10.88.0.0/16'. Expected 169.254.1.0/24.",
"Refusing to reuse network 'openshell-docker' with unexpected subnet '10.88.0.0/16'. Expected 10.87.0.0/24.",
],
[
{
Expand Down Expand Up @@ -1049,7 +1048,7 @@ describe("preparePortableExperimentalHost", () => {
const docker = vi
.fn<(args: readonly string[], env: NodeJS.ProcessEnv) => SpawnResult>()
.mockReturnValueOnce(result())
.mockReturnValueOnce(result(0, "1 true"))
.mockReturnValueOnce(result(0, "1|true|"))
.mockReturnValueOnce(result());

preparePortableExperimentalHost(
Expand Down Expand Up @@ -1113,7 +1112,7 @@ describe("preparePortableExperimentalHost", () => {
const docker = vi
.fn<(args: readonly string[], env: NodeJS.ProcessEnv) => SpawnResult>()
.mockReturnValueOnce(result())
.mockReturnValueOnce(result(0, `1 true ${PORTABLE_REGISTRY_IP}`));
.mockReturnValueOnce(result(0, `1|true|${PORTABLE_REGISTRY_IP}`));

try {
const prepared = preparePortableExperimentalHost(scope.env, {
Expand Down Expand Up @@ -1370,7 +1369,7 @@ describe("preparePortableExperimentalHost", () => {
const docker = vi
.fn<(args: readonly string[], env: NodeJS.ProcessEnv) => SpawnResult>()
.mockReturnValueOnce(result())
.mockReturnValueOnce(result(0, `1 true ${PORTABLE_REGISTRY_IP}`));
.mockReturnValueOnce(result(0, `1|true|${PORTABLE_REGISTRY_IP}`));

const prepared = preparePortableExperimentalHost(
{ NEMOCLAW_EXPERIMENTAL_PROFILE: "portable" },
Expand Down
Loading
Loading