nylorun 0.21.0-beta → 0.22.0-beta

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # nylorun
2
2
 
3
+ ## 0.22.0-beta
4
+
5
+ ### Minor Changes
6
+
7
+ - 77902da: `nylorun local sandbox enable --probe-ports <allowed>,<denied>` publishes the NetworkPolicy probe's listener on those two host ports instead of two free ones. A firewall in front of the Docker host that opens only named ports to the pods, such as a cloud VPC's, can then open them; without it, the probe's random ports never reach the host and `enable` refuses the cluster. The ports must differ from each other and from the sandbox ports.
8
+
9
+ ### Patch Changes
10
+
11
+ - 41b5741: `nylorun local sandbox enable` waits for a newly installed agent-sandbox CRD by polling its `Established` condition. It used `kubectl wait --for=condition=Established`, which fails at once, with ".status.conditions accessor error", when the API server has not yet given the CRD any conditions; GKE answers that way right after the apply.
12
+
3
13
  ## 0.21.0-beta
4
14
 
5
15
  ### Minor Changes
package/README.md CHANGED
@@ -117,7 +117,7 @@ nylorun local ls [--json] # the Tenants on this machine, with their sta
117
117
  nylorun local delete <tenant> --yes # remove a Tenant: containers, volumes, Host root and vault key
118
118
  nylorun local logs [service] [--tenant <name>] [-f] [--tail <n>] # postgres, restate, s2-lite, rustfs, gateway, runtime, harness, studio, sandboxes
119
119
  nylorun local reset [--tenant <name>] [--yes] # delete the Tenant's volumes, Tenant directory and vault key; the next start creates it anew
120
- nylorun local sandbox enable --context <name> [--tenant <name>] [--host-address <ip>] [--bind-address <ip>] [--no-pull]
120
+ nylorun local sandbox enable --context <name> [--tenant <name>] [--host-address <ip>] [--bind-address <ip>] [--probe-ports <allowed>,<denied>] [--no-pull]
121
121
  nylorun local sandbox disable [--tenant <name>] [--delete-namespace]
122
122
  nylorun local sandbox status [--tenant <name>] [--json] # pods on a Kubernetes context (see Sandboxes)
123
123
  nylorun local telemetry [status|enable|disable] # Studio's anonymous usage analytics
@@ -428,6 +428,10 @@ with a probe that the cluster enforces NetworkPolicy (and refuses it otherwise),
428
428
  lets pods reach only three ports on the Docker host, and adds the `sandboxes`
429
429
  service to the Tenant: the one container holding the cluster credentials
430
430
  (`<Host root>/sandboxes/`). On kind under Linux, pass `--host-address 172.17.0.1`.
431
+ The probe publishes two free ports on the Docker host. Behind a firewall that
432
+ opens only named ports to the pods, such as a cloud VPC's, pass two of them with
433
+ `--probe-ports <allowed>,<denied>`. Open both, so that only NetworkPolicy can
434
+ block the denied one.
431
435
  `nylorun local sandbox disable` removes the service (`--delete-namespace` also deletes
432
436
  the namespace and every sandbox in it). Sessions run in a pod when their
433
437
  Agent's manifest has `runtime.kind` `pod`.
@@ -1,8 +1,9 @@
1
1
  import { type StackDeps } from "../stack/commands.js";
2
2
  import type { FetchLike } from "../stack/studio-login.js";
3
3
  import { type ClusterFile } from "./cluster-file.js";
4
- import { type KubectlRunner } from "./kubectl.js";
5
- export declare const sandboxUsage = " sandbox enable --context <name> [--tenant <name>] [--host-address <ip>] [--bind-address <ip>] [--no-pull]\n run the Tenant's sandboxes as pods on that kubeconfig context (agent-sandbox v1.0.5)\n sandbox disable [--tenant <name>] [--delete-namespace]\n stop the sandboxes service and forget the cluster\n sandbox status [--tenant <name>] [--json]\n the cluster, the sandboxes service's readiness and what it reports";
4
+ import { type Kube, type KubectlRunner } from "./kubectl.js";
5
+ import { type ProbePorts } from "./probe.js";
6
+ export declare const sandboxUsage = " sandbox enable --context <name> [--tenant <name>] [--host-address <ip>] [--bind-address <ip>] [--probe-ports <allowed>,<denied>] [--no-pull]\n run the Tenant's sandboxes as pods on that kubeconfig context (agent-sandbox v1.0.5)\n sandbox disable [--tenant <name>] [--delete-namespace]\n stop the sandboxes service and forget the cluster\n sandbox status [--tenant <name>] [--json]\n the cluster, the sandboxes service's readiness and what it reports";
6
7
  /** The default workload image (D36), pre-pulled with the Runtime image. */
7
8
  export declare const DEFAULT_SANDBOX_IMAGE = "python:3.13-slim";
8
9
  export interface SandboxDeps {
@@ -14,6 +15,12 @@ export interface SandboxDeps {
14
15
  pollMs?: number;
15
16
  policyTimeoutMs?: number;
16
17
  }
18
+ /**
19
+ * Waits until the Sandbox CRD is Established. Polled, not `kubectl wait`: a CRD the API server has
20
+ * not given any conditions yet makes `kubectl wait --for=condition` fail at once instead of waiting
21
+ * (seen on GKE right after the apply).
22
+ */
23
+ export declare function waitForCrd(k: Kube, pollMs: number): Promise<void>;
17
24
  export interface InstallResult {
18
25
  cluster: ClusterFile;
19
26
  /** The ServiceAccount token. */
@@ -30,6 +37,7 @@ export declare function installCluster(deps: SandboxDeps, input: {
30
37
  tenant: string;
31
38
  hostAddress?: string;
32
39
  bindAddress?: string;
40
+ probePorts?: ProbePorts;
33
41
  prePull: readonly string[];
34
42
  ports: {
35
43
  harness: number;
@@ -9,15 +9,15 @@ import { readStackEnv } from "../stack/prepare.js";
9
9
  import { apiRoute, readClusterFile, removeClusterFiles, writeClusterFiles, } from "./cluster-file.js";
10
10
  import { contexts, kube } from "./kubectl.js";
11
11
  import { CONTROLLER_DEPLOYMENT, CONTROLLER_NAMESPACE, CONTROLLER_SHA256, CONTROLLER_URL, CONTROLLER_VERSION, networkPolicy, SANDBOX_API_VERSION, SANDBOX_CRD, tenantLabel, tenantManifests, tenantNamespace, TOKEN_SECRET, } from "./manifests.js";
12
- import { assertIPv4, runProbe } from "./probe.js";
12
+ import { assertIPv4, parseProbePorts, runProbe } from "./probe.js";
13
13
  import { cliTuning } from "../tuning.js";
14
- export const sandboxUsage = ` sandbox enable --context <name> [--tenant <name>] [--host-address <ip>] [--bind-address <ip>] [--no-pull]
14
+ export const sandboxUsage = ` sandbox enable --context <name> [--tenant <name>] [--host-address <ip>] [--bind-address <ip>] [--probe-ports <allowed>,<denied>] [--no-pull]
15
15
  run the Tenant's sandboxes as pods on that kubeconfig context (agent-sandbox ${CONTROLLER_VERSION})
16
16
  sandbox disable [--tenant <name>] [--delete-namespace]
17
17
  stop the sandboxes service and forget the cluster
18
18
  sandbox status [--tenant <name>] [--json]
19
19
  the cluster, the sandboxes service's readiness and what it reports`;
20
- const ENABLE_USAGE = "nylorun local sandbox enable --context <name> [--tenant <name>] [--host-address <ip>] [--bind-address <ip>] [--no-pull]";
20
+ const ENABLE_USAGE = "nylorun local sandbox enable --context <name> [--tenant <name>] [--host-address <ip>] [--bind-address <ip>] [--probe-ports <allowed>,<denied>] [--no-pull]";
21
21
  /** The default workload image (D36), pre-pulled with the Runtime image. */
22
22
  export const DEFAULT_SANDBOX_IMAGE = "python:3.13-slim";
23
23
  /** Preferred host ports for the Harness API, gates and egress (pods reach them). */
@@ -40,7 +40,7 @@ async function readKubeconfig(k) {
40
40
  * agent-sandbox: install the pinned release when absent; accept it when the pinned version
41
41
  * serves v1beta1; refuse anything else. Never upgrades or uninstalls.
42
42
  */
43
- async function ensureController(k, fetch, err) {
43
+ async function ensureController(k, fetch, err, pollMs) {
44
44
  const crd = await k.getJson(["crd", SANDBOX_CRD]);
45
45
  if (crd) {
46
46
  if (!crd.spec?.versions?.some((v) => v.name === SANDBOX_API_VERSION && v.served))
@@ -61,10 +61,26 @@ async function ensureController(k, fetch, err) {
61
61
  if (digest !== CONTROLLER_SHA256)
62
62
  throw new CliError(`${CONTROLLER_URL} has sha256 ${digest}, not the pinned ${CONTROLLER_SHA256}; not applying it.`, 1);
63
63
  await k.check("Installing agent-sandbox", ["apply", "--server-side", "-f", "-"], { input: manifest });
64
- await k.check("Waiting for the Sandbox CRD", ["wait", "--for=condition=Established", `crd/${SANDBOX_CRD}`, `--timeout=${cliTuning().sandbox.crdWaitSeconds}s`]);
64
+ await waitForCrd(k, pollMs);
65
65
  const tuning = cliTuning().sandbox;
66
66
  await k.check("Waiting for the agent-sandbox controller", ["rollout", "status", `deployment/${CONTROLLER_DEPLOYMENT}`, "-n", CONTROLLER_NAMESPACE, `--timeout=${tuning.rolloutWaitSeconds}s`], { timeoutMs: tuning.rolloutWaitSeconds * 1000 + tuning.kubectlMarginMs });
67
67
  }
68
+ /**
69
+ * Waits until the Sandbox CRD is Established. Polled, not `kubectl wait`: a CRD the API server has
70
+ * not given any conditions yet makes `kubectl wait --for=condition` fail at once instead of waiting
71
+ * (seen on GKE right after the apply).
72
+ */
73
+ export async function waitForCrd(k, pollMs) {
74
+ const deadline = Date.now() + cliTuning().sandbox.crdWaitSeconds * 1000;
75
+ for (;;) {
76
+ const crd = await k.getJson(["crd", SANDBOX_CRD]);
77
+ if (crd?.status?.conditions?.some((c) => c.type === "Established" && c.status === "True"))
78
+ return;
79
+ if (Date.now() > deadline)
80
+ throw new CliError(`The ${SANDBOX_CRD} CRD in ${k.context} was not Established within ${cliTuning().sandbox.crdWaitSeconds}s.`, 6);
81
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
82
+ }
83
+ }
68
84
  /** The ServiceAccount token the token controller writes into the Secret. */
69
85
  async function serviceAccountToken(k, namespace, pollMs) {
70
86
  const deadline = Date.now() + cliTuning().sandbox.tokenWaitMs;
@@ -93,7 +109,7 @@ export async function installCluster(deps, input) {
93
109
  catch (error) {
94
110
  throw new CliError(`Context ${input.context}: ${error instanceof Error ? error.message : String(error)}`, 6);
95
111
  }
96
- await ensureController(k, deps.fetch ?? stack.fetch, stack.err);
112
+ await ensureController(k, deps.fetch ?? stack.fetch, stack.err, pollMs);
97
113
  const namespace = tenantNamespace(input.tenant);
98
114
  const existing = await k.getJson(["namespace", namespace]);
99
115
  const owner = existing?.metadata?.labels?.["dev.nylorun.tenant"];
@@ -112,6 +128,7 @@ export async function installCluster(deps, input) {
112
128
  }, {
113
129
  ...(input.hostAddress ? { hostAddress: input.hostAddress } : {}),
114
130
  ...(input.bindAddress ? { bindAddress: input.bindAddress } : {}),
131
+ ...(input.probePorts ? { probePorts: input.probePorts } : {}),
115
132
  prePull: input.prePull,
116
133
  });
117
134
  await k.apply("Applying the namespace's NetworkPolicy", [
@@ -138,7 +155,7 @@ export async function installCluster(deps, input) {
138
155
  }
139
156
  async function enable(deps, args) {
140
157
  const { stack } = deps;
141
- const flags = parseStackFlags(args, { booleans: ["--no-pull"], values: ["--context", "--tenant", "--host-address", "--bind-address"] }, ENABLE_USAGE);
158
+ const flags = parseStackFlags(args, { booleans: ["--no-pull"], values: ["--context", "--tenant", "--host-address", "--bind-address", "--probe-ports"] }, ENABLE_USAGE);
142
159
  if (flags.rest.length)
143
160
  throw usageError(`Usage: ${ENABLE_USAGE}`);
144
161
  const context = flags.values.get("--context");
@@ -150,6 +167,8 @@ async function enable(deps, args) {
150
167
  assertIPv4(hostAddress, "--host-address");
151
168
  if (bindAddress)
152
169
  assertIPv4(bindAddress, "--bind-address");
170
+ const probePortsFlag = flags.values.get("--probe-ports");
171
+ const probePorts = probePortsFlag === undefined ? undefined : parseProbePorts(probePortsFlag);
153
172
  const name = flags.values.get("--tenant");
154
173
  const tenant = await selectTenant(stack, name === undefined ? {} : { name });
155
174
  await dockerPreflight(stack.docker);
@@ -169,12 +188,15 @@ async function enable(deps, args) {
169
188
  gates: await pick(PREFERRED_PORTS.gates, persisted?.gatesPort),
170
189
  egress: await pick(PREFERRED_PORTS.egress, persisted?.egressPort),
171
190
  };
191
+ if (probePorts && [probePorts.allowed, probePorts.denied].some((port) => taken.has(port)))
192
+ throw usageError(`--probe-ports ${probePortsFlag} overlaps the sandbox ports ${ports.harness}, ${ports.gates} and ${ports.egress}; pick two others.`);
172
193
  const images = stackImages(stack.env, { runtime: stack.runtimeVersion, studio: stack.studioVersion });
173
194
  const installed = await installCluster(deps, {
174
195
  context,
175
196
  tenant: tenant.name,
176
197
  ...(hostAddress ? { hostAddress } : {}),
177
198
  ...(bindAddress ? { bindAddress } : {}),
199
+ ...(probePorts ? { probePorts } : {}),
178
200
  prePull: flags.booleans.has("--no-pull") ? [] : [DEFAULT_SANDBOX_IMAGE, images.runtime],
179
201
  ports,
180
202
  });
@@ -11,8 +11,9 @@ import type { Kube } from "./kubectl.js";
11
11
  * to `<host>/32` on one port), must reach the allowed host port and nothing else: not the
12
12
  * peer pod, not another port on the host.
13
13
  *
14
- * The host ports are served by a temporary busybox container published on the bind address.
15
- * The namespace and the container are always removed.
14
+ * The host ports are served by a temporary busybox container published on the bind address: two
15
+ * free ports, or `--probe-ports` where a firewall in front of the host (a cloud VPC's) opens only
16
+ * named ports to the pods. The namespace and the container are always removed.
16
17
  */
17
18
  export interface ProbeDeps {
18
19
  kube: Kube;
@@ -35,10 +36,20 @@ export interface ProbeResult {
35
36
  /** Docker Desktop and OrbStack forward the host address to this machine's loopback. */
36
37
  export declare function defaultBind(context: string, hostAddress: string): string;
37
38
  export declare function assertIPv4(value: string, option: string): string;
39
+ /** The probe's two host ports. */
40
+ export interface ProbePorts {
41
+ /** The port the probe's policy allows. */
42
+ allowed: number;
43
+ /** The port it denies. */
44
+ denied: number;
45
+ }
46
+ /** `--probe-ports <allowed>,<denied>`: two different TCP ports. */
47
+ export declare function parseProbePorts(value: string): ProbePorts;
38
48
  /** The last IPv4 address `nslookup` prints for the name (the first is the DNS server). */
39
49
  export declare function parseNslookup(output: string): string | undefined;
40
50
  export declare function runProbe(deps: ProbeDeps, options: {
41
51
  hostAddress?: string;
42
52
  bindAddress?: string;
53
+ probePorts?: ProbePorts;
43
54
  prePull?: readonly string[];
44
55
  }): Promise<ProbeResult>;
@@ -15,6 +15,13 @@ export function assertIPv4(value, option) {
15
15
  throw new CliError(`${option} must be an IPv4 address: ${value}`, 2);
16
16
  return value;
17
17
  }
18
+ /** `--probe-ports <allowed>,<denied>`: two different TCP ports. */
19
+ export function parseProbePorts(value) {
20
+ const ports = value.split(",").map((part) => Number(part.trim()));
21
+ if (ports.length !== 2 || !ports.every((port) => Number.isInteger(port) && port > 0 && port < 65536) || ports[0] === ports[1])
22
+ throw new CliError(`--probe-ports must be two different TCP ports, <allowed>,<denied>: ${value}`, 2);
23
+ return { allowed: ports[0], denied: ports[1] };
24
+ }
18
25
  /** The last IPv4 address `nslookup` prints for the name (the first is the DNS server). */
19
26
  export function parseNslookup(output) {
20
27
  const answer = output.split(/Name:\s*\S+/).slice(1).join("\n");
@@ -59,10 +66,17 @@ export async function runProbe(deps, options) {
59
66
  throw fail("pods cannot resolve host.docker.internal. Pass --host-address with the Docker host's address as pods reach it (172.17.0.1 for kind on Linux).");
60
67
  }
61
68
  const bindAddress = options.bindAddress ?? defaultBind(kube.context, hostAddress);
62
- const allowed = await deps.ports.pickFree();
63
- let denied = await deps.ports.pickFree();
64
- if (denied === allowed)
69
+ let allowed;
70
+ let denied;
71
+ if (options.probePorts) {
72
+ ({ allowed, denied } = options.probePorts);
73
+ }
74
+ else {
75
+ allowed = await deps.ports.pickFree();
65
76
  denied = await deps.ports.pickFree();
77
+ if (denied === allowed)
78
+ denied = await deps.ports.pickFree();
79
+ }
66
80
  const started = await docker.run([
67
81
  "run", "--detach", "--rm", "--name", listener, "--label", "dev.nylorun.probe=true",
68
82
  "--publish", `${bindAddress}:${allowed}:8080`, "--publish", `${bindAddress}:${denied}:8081`,
package/dist/tuning.d.ts CHANGED
@@ -55,7 +55,7 @@ export declare const DEFAULT_CLI_TUNING: {
55
55
  kubectlMarginMs: number;
56
56
  /** Downloading the agent-sandbox manifest. */
57
57
  manifestDownloadMs: number;
58
- /** `kubectl wait` for the CRD, and `rollout status` of the controller, in seconds. */
58
+ /** How long the CRD may take to be Established, and `rollout status` of the controller, in seconds. */
59
59
  crdWaitSeconds: number;
60
60
  rolloutWaitSeconds: number;
61
61
  /** How long to wait for the service account's token Secret. */
package/dist/tuning.js CHANGED
@@ -67,7 +67,7 @@ export const DEFAULT_CLI_TUNING = deepFreeze({
67
67
  kubectlMarginMs: 20_000,
68
68
  /** Downloading the agent-sandbox manifest. */
69
69
  manifestDownloadMs: 60_000,
70
- /** `kubectl wait` for the CRD, and `rollout status` of the controller, in seconds. */
70
+ /** How long the CRD may take to be Established, and `rollout status` of the controller, in seconds. */
71
71
  crdWaitSeconds: 60,
72
72
  rolloutWaitSeconds: 180,
73
73
  /** How long to wait for the service account's token Secret. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nylorun",
3
- "version": "0.21.0-beta",
3
+ "version": "0.22.0-beta",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "description": "Set up and run local Nylorun Tenants (Runtime and Studio) with Docker Compose, and act on a Tenant through its Runtime and Management APIs",