@kici-dev/orchestrator 0.18.0 → 0.20.0

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/dist/cli.js CHANGED
@@ -15246,19 +15246,60 @@ function writeInstallManifest(instanceDir, fresh, launch, opts = {}) {
15246
15246
  function resolveNpmInstallTarget(spec, component, opts) {
15247
15247
  const entryRe = new RegExp(`[/\\\\]@kici-dev[/\\\\]${component}[/\\\\]dist[/\\\\](?:server|standalone)\\.js$`);
15248
15248
  const entry = [spec.execPath, ...spec.args].find((a) => entryRe.test(a));
15249
- if (!entry) return null;
15249
+ if (!entry) return {
15250
+ ok: false,
15251
+ reason: "no-entry"
15252
+ };
15250
15253
  const owningPackage = firstGlobalPackage(entry);
15251
- if (!owningPackage) return null;
15254
+ if (!owningPackage) return {
15255
+ ok: false,
15256
+ reason: "no-entry"
15257
+ };
15252
15258
  const p = opts.windows ? path.win32 : path.posix;
15253
- const npmBin = opts.windows ? "npm.cmd" : "npm";
15254
- const npmPath = p.join(p.dirname(spec.execPath), npmBin);
15259
+ const globalPrefix = globalPrefixOf(entry, opts.windows);
15260
+ if (!globalPrefix) return {
15261
+ ok: false,
15262
+ reason: "not-global",
15263
+ entry
15264
+ };
15265
+ const nodeDir = p.dirname(spec.execPath);
15266
+ const npmPath = p.join(nodeDir, opts.windows ? "npm.cmd" : "npm");
15267
+ const npmCliJs = opts.windows ? p.join(nodeDir, "node_modules", "npm", "bin", "npm-cli.js") : p.join(nodeDir, "..", "lib", "node_modules", "npm", "bin", "npm-cli.js");
15268
+ const npmInvocation = (opts.exists ?? fs.existsSync)(npmCliJs) ? {
15269
+ cmd: spec.execPath,
15270
+ preArgs: [npmCliJs]
15271
+ } : {
15272
+ cmd: npmPath,
15273
+ preArgs: []
15274
+ };
15255
15275
  return {
15256
- nodeExecPath: spec.execPath,
15257
- npmPath,
15258
- owningPackage
15276
+ ok: true,
15277
+ target: {
15278
+ nodeExecPath: spec.execPath,
15279
+ npmPath,
15280
+ owningPackage,
15281
+ globalPrefix,
15282
+ npmInvocation
15283
+ }
15259
15284
  };
15260
15285
  }
15261
15286
  /**
15287
+ * The npm global prefix holding `entry`, split on its FIRST `node_modules`
15288
+ * segment like {@link firstGlobalPackage}. Windows lays a global install out as
15289
+ * `<prefix>\node_modules\<pkg>`; posix as `<prefix>/lib/node_modules/<pkg>`, so
15290
+ * there the segment before must be `lib`, which tells a global install from a
15291
+ * local `node_modules` tree. Null for anything else.
15292
+ */
15293
+ function globalPrefixOf(entry, windows) {
15294
+ const p = windows ? path.win32 : path.posix;
15295
+ const segments = entry.split(/[/\\]/);
15296
+ const idx = segments.indexOf("node_modules");
15297
+ if (idx < 1) return null;
15298
+ if (windows) return segments.slice(0, idx).join(p.sep) || null;
15299
+ if (idx < 2 || segments[idx - 1] !== "lib") return null;
15300
+ return segments.slice(0, idx - 1).join(p.sep) || p.sep;
15301
+ }
15302
+ /**
15262
15303
  * The package under the FIRST `node_modules` segment (the global root), which
15263
15304
  * `npm install -g` must target; `@scope/name` keeps both segments. Null
15264
15305
  * without a `node_modules` segment.
@@ -22300,6 +22341,25 @@ function encryptPrivateKey(privateKey, secretKey) {
22300
22341
  return encrypt(privateKey.toString("base64"), key, 1, PRIVATE_KEY_AAD).data;
22301
22342
  }
22302
22343
  createLogger({ prefix: "job-secret-seal" });
22344
+ //#endregion
22345
+ //#region src/agent/agent-version.ts
22346
+ /**
22347
+ * Parse `MAJOR.MINOR.PATCH`, or `null` for a non-version. The `-prerelease`
22348
+ * suffix is dropped rather than ordered: it is a build counter (`0.5.0-9159`),
22349
+ * and strict semver would order it BELOW `0.5.0`, reading every prerelease
22350
+ * agent as too old for a feature it carries.
22351
+ */
22352
+ function parseVersionBase(version) {
22353
+ const parts = version.trim().split("-")[0].split(".");
22354
+ if (parts.length !== 3) return null;
22355
+ const nums = parts.map((part) => /^\d+$/.test(part) ? Number(part) : NaN);
22356
+ if (nums.some((n) => Number.isNaN(n))) return null;
22357
+ return [
22358
+ nums[0],
22359
+ nums[1],
22360
+ nums[2]
22361
+ ];
22362
+ }
22303
22363
  runtimeLabel(RuntimeFact.enum.docker), runtimeLabel(RuntimeFact.enum.podman);
22304
22364
  //#endregion
22305
22365
  //#region src/helpers/run-detached.ts
@@ -24091,6 +24151,117 @@ async function preflightBareMetalBinaryPaths(args) {
24091
24151
  Nothing was installed and the service was not restarted. Fix binaryPath and run the upgrade again, or pass ${SKIP_BINARY_PATH_CHECK_FLAG} to upgrade anyway.`);
24092
24152
  }
24093
24153
  //#endregion
24154
+ //#region src/cli/commands/shared/container-agent-image-check.ts
24155
+ /**
24156
+ * The container-scaler agent image check `kici-admin orchestrator upgrade`
24157
+ * runs before it restarts the service.
24158
+ *
24159
+ * A container scaler spawns each agent from the image its label set names, not
24160
+ * from the upgraded orchestrator. So an upgrade leaves container agents on the
24161
+ * old release when a label set pins an older `kici-agent:<version>`, or names
24162
+ * the moving `:latest` tag with the default `imagePullPolicy: IfNotPresent`: a
24163
+ * host that already pulled `:latest` keeps that image. The check only warns:
24164
+ * it never rewrites the operator's scaler config and never stops the upgrade.
24165
+ */
24166
+ /** The repository name of the published agent image, under any registry or mirror. */
24167
+ const AGENT_IMAGE_NAME = "kici-agent";
24168
+ /** The moving tag an untagged image reference resolves to. */
24169
+ const LATEST_TAG = "latest";
24170
+ /**
24171
+ * Split `[registry/]path/name[:tag][@digest]`. A `:` before the last `/` is a
24172
+ * registry port, not a tag.
24173
+ */
24174
+ function parseImageRef(image) {
24175
+ const at = image.indexOf("@");
24176
+ const named = at === -1 ? image : image.slice(0, at);
24177
+ const digest = at === -1 ? null : image.slice(at + 1);
24178
+ const colon = named.lastIndexOf(":");
24179
+ if (colon > named.lastIndexOf("/")) return {
24180
+ repository: named.slice(0, colon),
24181
+ tag: named.slice(colon + 1),
24182
+ digest
24183
+ };
24184
+ return {
24185
+ repository: named,
24186
+ tag: null,
24187
+ digest
24188
+ };
24189
+ }
24190
+ /** True when the repository is the KiCI agent image, from quay.io or a mirror. */
24191
+ function isAgentImage(repository) {
24192
+ return repository.slice(repository.lastIndexOf("/") + 1) === AGENT_IMAGE_NAME;
24193
+ }
24194
+ /** -1, 0 or 1 as `a` is an older, the same or a newer release than `b`; null for a non-version. */
24195
+ function compareReleases(a, b) {
24196
+ const left = parseVersionBase(a.replace(/^v/, ""));
24197
+ const right = parseVersionBase(b.replace(/^v/, ""));
24198
+ if (!left || !right) return null;
24199
+ for (let i = 0; i < 3; i++) if (left[i] !== right[i]) return left[i] < right[i] ? -1 : 1;
24200
+ return 0;
24201
+ }
24202
+ /**
24203
+ * Every container-scaler label set whose agent image an upgrade to
24204
+ * `targetVersion` leaves unchanged. An empty result means each agent image is
24205
+ * pinned to the target (or newer), pulled on every spawn, or not a KiCI agent.
24206
+ */
24207
+ function checkContainerAgentImages(args) {
24208
+ const { config } = args;
24209
+ const bare = args.targetVersion?.trim().replace(/^v/, "") ?? null;
24210
+ const targetVersion = bare && parseVersionBase(bare) ? bare : null;
24211
+ const where = args.scalerConfigDir ? `${args.scalerConfigPath} (or the file under ${args.scalerConfigDir} that declares it)` : args.scalerConfigPath;
24212
+ const findings = [];
24213
+ for (const entry of config.scalers) {
24214
+ if (entry.type !== ScalerBackendType.enum.container) continue;
24215
+ for (const ls of entry.labelSets) {
24216
+ if (!ls.image) continue;
24217
+ const ref = parseImageRef(ls.image);
24218
+ if (!isAgentImage(ref.repository)) continue;
24219
+ const pinned = targetVersion ? `${ref.repository}:${targetVersion}` : null;
24220
+ const prefix = `container scaler "${entry.name}", label set [${ls.labels.join(", ")}]: image ${ls.image}`;
24221
+ const finding = (issue, message) => ({
24222
+ issue,
24223
+ scaler: entry.name,
24224
+ labels: ls.labels,
24225
+ image: ls.image,
24226
+ message: `${prefix} ${message}`
24227
+ });
24228
+ const policy = ls.imagePullPolicy ?? ImagePullPolicy.enum.IfNotPresent;
24229
+ if ((ref.tag === null || ref.tag === LATEST_TAG) && ref.digest === null && policy === ImagePullPolicy.enum.IfNotPresent) {
24230
+ const pin = pinned ? `set "image: ${pinned}"` : `set image to the kici-agent release you upgrade to (${ref.repository}:<version>)`;
24231
+ findings.push(finding("moving-tag", `with imagePullPolicy IfNotPresent keeps the agent image this host already pulled, so the upgrade does not change the container agents. In ${where}, ${pin} on this label set, or set "imagePullPolicy: Always" to pull ${LATEST_TAG} on every spawn.`));
24232
+ continue;
24233
+ }
24234
+ if (!targetVersion || ref.tag === null) continue;
24235
+ if (compareReleases(ref.tag, targetVersion) === -1) findings.push(finding("older-pin", `is older than the upgrade target ${targetVersion}, so the container agents keep running ${ref.tag}. In ${where}, set "image: ${pinned}" on this label set.`));
24236
+ }
24237
+ }
24238
+ return findings;
24239
+ }
24240
+ /**
24241
+ * Print one warning per container label set whose agent image the upgrade
24242
+ * leaves unchanged. Never throws, and stays silent when the env file or the
24243
+ * scaler config cannot be read: the bare-metal `binaryPath` check, which runs
24244
+ * first on the same files, already reports that.
24245
+ */
24246
+ async function warnAboutContainerAgentImages(args) {
24247
+ let location;
24248
+ let config;
24249
+ try {
24250
+ location = scalerConfigLocation(fs.readFileSync(args.envFilePath, "utf-8"), args.workingDirectory);
24251
+ if (!location) return;
24252
+ config = await loadScalerConfig(location.configPath, location.configDir);
24253
+ } catch {
24254
+ return;
24255
+ }
24256
+ const findings = checkContainerAgentImages({
24257
+ config,
24258
+ targetVersion: args.targetVersion,
24259
+ scalerConfigPath: location.configPath,
24260
+ ...location.configDir ? { scalerConfigDir: location.configDir } : {}
24261
+ });
24262
+ for (const f of findings) args.warn(`Warning: ${f.message}`);
24263
+ }
24264
+ //#endregion
24094
24265
  //#region src/cli/commands/shared/versioned-upgrade.ts
24095
24266
  /**
24096
24267
  * Versioned-directory upgrade shared by the kici-admin upgrade commands: each
@@ -24100,14 +24271,16 @@ Nothing was installed and the service was not restarted. Fix binaryPath and run
24100
24271
  */
24101
24272
  init_service();
24102
24273
  /**
24103
- * Check every bare-metal `binaryPath` of an orchestrator before the upgrade
24104
- * installs or restarts anything. `spec` is the unit's launch command: the
24105
- * `kici-agent` installed with its runtime is the one each `binaryPath` should
24274
+ * Check an orchestrator's scaler config before the upgrade installs or
24275
+ * restarts anything. `spec` is the unit's launch command: the `kici-agent`
24276
+ * installed with its runtime is the one each bare-metal `binaryPath` should
24106
24277
  * name. `expectedVersion` is what that agent runs after the upgrade, when the
24107
24278
  * caller knows it. Throws when a `binaryPath` would stop the restarted service,
24108
- * unless `skip` is set: then every finding is a warning.
24279
+ * unless `skip` is set: then every finding is a warning. Then warns about each
24280
+ * container label set whose agent image stays behind `targetVersion`, the
24281
+ * release the service runs after the upgrade.
24109
24282
  */
24110
- async function checkBareMetalBinaryPathsBeforeUpgrade(args) {
24283
+ async function checkScalersBeforeUpgrade(args) {
24111
24284
  if (args.component !== "orchestrator" || args.platform === "compose") return;
24112
24285
  const expectedAgent = args.spec ? agentBinaryFromLaunch(args.spec, isWindows(args.platform)) : null;
24113
24286
  await preflightBareMetalBinaryPaths({
@@ -24120,6 +24293,12 @@ async function checkBareMetalBinaryPathsBeforeUpgrade(args) {
24120
24293
  skip: args.skip,
24121
24294
  warn: (line) => console.warn(line)
24122
24295
  });
24296
+ await warnAboutContainerAgentImages({
24297
+ envFilePath: args.config.envFilePath,
24298
+ workingDirectory: args.config.workingDirectory,
24299
+ targetVersion: args.targetVersion,
24300
+ warn: (line) => console.warn(line)
24301
+ });
24123
24302
  }
24124
24303
  /**
24125
24304
  * Resolve the upgrade target through {@link resolveInstanceTarget}: `instanceDir`,
@@ -24448,14 +24627,18 @@ function planNpmSourceUpgrade(opts) {
24448
24627
  kind: "error",
24449
24628
  reason: "self-driving upgrade requires --version <target> (the version to install). Pass --restart-only to restart the already-installed package without installing."
24450
24629
  };
24451
- const target = spec ? resolveNpmInstallTarget(spec, component, { windows }) : null;
24452
- if (!target) return {
24630
+ const resolution = spec ? resolveNpmInstallTarget(spec, component, { windows }) : null;
24631
+ if (resolution && !resolution.ok && resolution.reason === "not-global") return {
24632
+ kind: "error",
24633
+ reason: `npm-source upgrade cannot self-install: the unit's launch target ${resolution.entry} is not inside a global npm install, so the unit's npm has no global prefix that holds it. The unit runs ${spec.execPath}; this kici-admin runs ${process.execPath}. Use an archive upgrade (--from <archive> / --url <url> + --version), or pre-install the package and re-run with --restart-only.`
24634
+ };
24635
+ if (!resolution?.ok) return {
24453
24636
  kind: "error",
24454
24637
  reason: "npm-source upgrade cannot self-install: the unit's launch target is a custom --binary install or a non-node_modules path, so there is no global package to update. Use an archive upgrade (--from <archive> / --url <url> + --version), or pre-install the package and re-run with --restart-only."
24455
24638
  };
24456
24639
  return {
24457
24640
  kind: "self-drive",
24458
- target,
24641
+ target: resolution.target,
24459
24642
  version: requestedVersion
24460
24643
  };
24461
24644
  }
@@ -24471,9 +24654,14 @@ function windowsShellCommand(cmd, args) {
24471
24654
  for (const arg of args) if (!/^[A-Za-z0-9@/._+-]+$/.test(arg)) throw new Error(`cannot pass "${arg}" to npm: it holds a character cmd.exe reads as syntax`);
24472
24655
  return [`"${cmd}"`, ...args].join(" ");
24473
24656
  }
24657
+ /**
24658
+ * Only a Windows batch file (`npm.cmd`) goes through cmd.exe; the pinned
24659
+ * `node.exe` running npm-cli.js spawns directly, with no shell to parse its
24660
+ * `C:\…` arguments.
24661
+ */
24474
24662
  const defaultSpawnRunner = (cmd, args, opts) => {
24475
24663
  const env = opts?.env ?? process.env;
24476
- const r = process.platform === "win32" ? spawnSync(windowsShellCommand(cmd, args), {
24664
+ const r = process.platform === "win32" && /\.cmd$/i.test(cmd) ? spawnSync(windowsShellCommand(cmd, args), {
24477
24665
  encoding: "utf-8",
24478
24666
  shell: true,
24479
24667
  env
@@ -24488,15 +24676,40 @@ const defaultSpawnRunner = (cmd, args, opts) => {
24488
24676
  };
24489
24677
  };
24490
24678
  /**
24491
- * The environment npm runs in: the unit's pinned node first on PATH, because
24492
- * npm derives its global prefix from the node that runs it.
24679
+ * The environment npm runs in: the unit's pinned node first on PATH, so a
24680
+ * wrapper fallback (`npmPath`, an `env node` shebang) resolves that node. The
24681
+ * caller's `npm_config_*` stay, so an operator-set registry or proxy still
24682
+ * applies.
24683
+ *
24684
+ * On the `npm.cmd` fallback the prefix goes through `npm_config_prefix`:
24685
+ * {@link windowsShellCommand} refuses the `\`, `:` and `=` a `--prefix=C:\…`
24686
+ * argument holds. Every other invocation passes `--prefix` on the command
24687
+ * line ({@link npmArgs}), which npm ranks above the environment.
24493
24688
  */
24494
24689
  function npmEnv(target) {
24495
24690
  const pinnedBinDir = path.dirname(target.nodeExecPath);
24496
- return {
24691
+ const env = {
24497
24692
  ...process.env,
24498
24693
  PATH: `${pinnedBinDir}${path.delimiter}${process.env.PATH ?? ""}`
24499
24694
  };
24695
+ if (prefixViaEnv(target)) {
24696
+ for (const key of Object.keys(env)) if (/^npm_config_(global_)?prefix$/i.test(key)) delete env[key];
24697
+ env.npm_config_prefix = target.globalPrefix;
24698
+ }
24699
+ return env;
24700
+ }
24701
+ /** Whether the prefix travels in the environment: the `npm.cmd` fallback. */
24702
+ function prefixViaEnv(target) {
24703
+ return target.npmInvocation.preArgs.length === 0 && /\.cmd$/i.test(target.npmInvocation.cmd);
24704
+ }
24705
+ /** The argv for one npm call: npm-cli.js first when the pinned node runs it, `--prefix` last. */
24706
+ function npmArgs(target, args) {
24707
+ const prefix = prefixViaEnv(target) ? [] : [`--prefix=${target.globalPrefix}`];
24708
+ return [
24709
+ ...target.npmInvocation.preArgs,
24710
+ ...args,
24711
+ ...prefix
24712
+ ];
24500
24713
  }
24501
24714
  /**
24502
24715
  * Whether `version` reads KICI_ENV_FILE, asked of the unit's npm registry. Such
@@ -24504,12 +24717,12 @@ function npmEnv(target) {
24504
24717
  * KiCI package of that version depends on.
24505
24718
  */
24506
24719
  function npmReleaseReadsEnvFile(target, version, run = defaultSpawnRunner) {
24507
- const res = run(target.npmPath, [
24720
+ const res = run(target.npmInvocation.cmd, npmArgs(target, [
24508
24721
  "view",
24509
24722
  `@kici-dev/shared@${version}`,
24510
24723
  "exports",
24511
24724
  "--json"
24512
- ], { env: npmEnv(target) });
24725
+ ]), { env: npmEnv(target) });
24513
24726
  if (res.status !== 0) return {
24514
24727
  ok: false,
24515
24728
  error: res.stderr.trim() || `npm view exited with ${res.status}`
@@ -24538,20 +24751,20 @@ function npmReleaseReadsEnvFile(target, version, run = defaultSpawnRunner) {
24538
24751
  };
24539
24752
  }
24540
24753
  /**
24541
- * Install `<owningPackage>@<version>` globally under the unit's pinned node, so
24542
- * it lands in the prefix the unit's ExecStart resolves from. npm derives its
24543
- * global prefix from the node that runs npm-cli.js, and the unit's
24544
- * `<pinned>/bin/npm` (an `env node` shebang or a version-manager wrapper)
24545
- * resolves `node` off PATH; run bare it would inherit the shell's active node,
24546
- * the mise/nvm/asdf drift this upgrade fixes. Hence the pinned bin dir goes
24547
- * first on PATH ({@link npmEnv}).
24754
+ * Install `<owningPackage>@<version>` globally into the prefix the unit's
24755
+ * ExecStart resolves from, with the unit's pinned node. Without an explicit
24756
+ * prefix npm derives one from the node that runs npm-cli.js, or takes the
24757
+ * `npm_config_prefix` the caller's environment carries (`npx` exports its
24758
+ * own), so the install would land where the unit never reads it. The pinned
24759
+ * node runs npm-cli.js directly, skipping a version-manager wrapper at
24760
+ * `<pinned>/bin/npm`, which also keeps the shell's active node out of it.
24548
24761
  */
24549
24762
  function installGlobalPackage(target, version, run = defaultSpawnRunner) {
24550
- const res = run(target.npmPath, [
24763
+ const res = run(target.npmInvocation.cmd, npmArgs(target, [
24551
24764
  "install",
24552
24765
  "-g",
24553
24766
  `${target.owningPackage}@${version}`
24554
- ], { env: npmEnv(target) });
24767
+ ]), { env: npmEnv(target) });
24555
24768
  return {
24556
24769
  ok: res.status === 0,
24557
24770
  stderr: res.stderr
@@ -24673,12 +24886,13 @@ async function performVersionedUpgrade(component, opts, hooks = {}) {
24673
24886
  console.error(`Error: service "${config.name}" is not installed`);
24674
24887
  process.exit(1);
24675
24888
  }
24676
- await checkBareMetalBinaryPathsBeforeUpgrade({
24889
+ await checkScalersBeforeUpgrade({
24677
24890
  component,
24678
24891
  config,
24679
24892
  platform,
24680
24893
  spec: await manager.readLaunchSpec(config),
24681
24894
  expectedVersion: null,
24895
+ targetVersion: version,
24682
24896
  willInstall: false,
24683
24897
  skip: opts.skipBinaryPathCheck ?? false
24684
24898
  });
@@ -24834,12 +25048,13 @@ async function restartOnlyUpgrade(args) {
24834
25048
  console.error(`Error: ${verdict.reason}`);
24835
25049
  process.exit(1);
24836
25050
  }
24837
- await checkBareMetalBinaryPathsBeforeUpgrade({
25051
+ await checkScalersBeforeUpgrade({
24838
25052
  component: args.component,
24839
25053
  config,
24840
25054
  platform,
24841
25055
  spec,
24842
25056
  expectedVersion: null,
25057
+ targetVersion: verdict.version,
24843
25058
  willInstall: false,
24844
25059
  skip: opts.skipBinaryPathCheck ?? false
24845
25060
  });
@@ -24870,12 +25085,13 @@ async function restartOnlyUpgrade(args) {
24870
25085
  async function performSelfDrivingInstall(args) {
24871
25086
  const { component, config, resolvedInstance, manager, action, opts, platform, spec } = args;
24872
25087
  const { target, version } = action;
24873
- await checkBareMetalBinaryPathsBeforeUpgrade({
25088
+ await checkScalersBeforeUpgrade({
24874
25089
  component,
24875
25090
  config,
24876
25091
  platform,
24877
25092
  spec,
24878
25093
  expectedVersion: `${target.owningPackage}@${version}`,
25094
+ targetVersion: version,
24879
25095
  willInstall: true,
24880
25096
  skip: opts.skipBinaryPathCheck ?? false
24881
25097
  });
@@ -24886,15 +25102,14 @@ async function performSelfDrivingInstall(args) {
24886
25102
  if (!verdict.reads) throw new Error(`refusing to install ${release} for "${config.name}": ${envFileRefusal(release, config.envFilePath)} The service and the installed package were not changed.`);
24887
25103
  }
24888
25104
  if (!opts.yes) {
24889
- console.log(`This will install ${target.owningPackage}@${version} using ${target.npmPath}`);
24890
- console.log(` (node runtime: ${target.nodeExecPath}), then restart "${config.name}".`);
25105
+ console.log(`This will install ${target.owningPackage}@${version} with ${target.nodeExecPath} into ${target.globalPrefix} (the unit's runtime), then restart "${config.name}".`);
24891
25106
  console.log("");
24892
25107
  if (!await confirm$1("Proceed with upgrade?")) {
24893
25108
  console.log("Upgrade cancelled.");
24894
25109
  return;
24895
25110
  }
24896
25111
  }
24897
- console.log(`Installing ${target.owningPackage}@${version} under the unit's runtime...`);
25112
+ console.log(`Installing ${target.owningPackage}@${version} with ${target.nodeExecPath} into ${target.globalPrefix} (the unit's runtime)...`);
24898
25113
  const result = installGlobalPackage(target, version, args.run);
24899
25114
  if (!result.ok) {
24900
25115
  const hint = /EACCES|permission denied|EPERM/i.test(result.stderr) ? " The unit's global prefix is not writable — re-run with privileges matching the unit's runtime." : "";
@@ -25034,7 +25249,7 @@ async function switchToInstalledVersion(args) {
25034
25249
  action: `switch "${config.name}" to ${targetVersion}`,
25035
25250
  config
25036
25251
  });
25037
- await warnAboutBareMetalBinaryPaths(args);
25252
+ await warnAboutScalers(args);
25038
25253
  if (!await assertSchemaAllowsSwitch({
25039
25254
  component,
25040
25255
  targetVersion,
@@ -25068,18 +25283,20 @@ async function switchToInstalledVersion(args) {
25068
25283
  });
25069
25284
  }
25070
25285
  /**
25071
- * Print the bare-metal `binaryPath` diagnosis before `--rollback` or `--pick`
25072
- * restarts the service. Never refuses and never throws: both are recovery
25073
- * paths, and a check that cannot run must not block a recovery.
25286
+ * Print the bare-metal `binaryPath` and container agent image diagnoses before
25287
+ * `--rollback` or `--pick` restarts the service. Never refuses and never
25288
+ * throws: both are recovery paths, and a check that cannot run must not block
25289
+ * a recovery.
25074
25290
  */
25075
- async function warnAboutBareMetalBinaryPaths(args) {
25291
+ async function warnAboutScalers(args) {
25076
25292
  try {
25077
- await checkBareMetalBinaryPathsBeforeUpgrade({
25293
+ await checkScalersBeforeUpgrade({
25078
25294
  component: args.component,
25079
25295
  config: args.config,
25080
25296
  platform: args.platform,
25081
25297
  spec: await args.manager.readLaunchSpec(args.config).catch(() => null),
25082
25298
  expectedVersion: null,
25299
+ targetVersion: args.targetVersion,
25083
25300
  willInstall: false,
25084
25301
  skip: true
25085
25302
  });
@@ -26603,7 +26820,7 @@ init_manifest();
26603
26820
  const ALL_PLATFORMS = AgentPlatform.options;
26604
26821
  /** The orchestrator's own version (single-version invariant) = the packaged agent version. */
26605
26822
  function resolveKiciVersion() {
26606
- return "0.18.0";
26823
+ return "0.20.0";
26607
26824
  }
26608
26825
  /** Parse the --platform value: default set | single | CSV | `all`. */
26609
26826
  function parsePlatforms(raw) {
@@ -35310,6 +35527,7 @@ function buildAgentContainerHostConfig(input) {
35310
35527
  ...typeof cpus === "number" && cpus > 0 ? { NanoCpus: Math.round(cpus * 1e9) } : {},
35311
35528
  ...input.binds && input.binds.length > 0 ? { Binds: input.binds } : {},
35312
35529
  ...input.extraHosts && input.extraHosts.length > 0 ? { ExtraHosts: input.extraHosts } : {},
35530
+ ...input.supplementaryGroups && input.supplementaryGroups.length > 0 ? { GroupAdd: input.supplementaryGroups.map(String) } : {},
35313
35531
  AutoRemove: false,
35314
35532
  PidsLimit: AGENT_CONTAINER_PIDS_LIMIT
35315
35533
  };
@@ -35374,6 +35592,19 @@ async function detectRuntime(runtimeHint) {
35374
35592
  * Create a LogCapture from a running container's stdout/stderr streams.
35375
35593
  * Uses dockerode's demuxStream to strip Docker's 8-byte multiplexed headers.
35376
35594
  */
35595
+ /**
35596
+ * The cpu / memory ceiling for one spawn. `effectiveLimits` from ScalerManager
35597
+ * wins: it already folds in job overrides, label-set and scaler defaults.
35598
+ * Otherwise the label set's (or the scaler's default) `resources.limits` apply.
35599
+ */
35600
+ function resolveSpawnLimits(effectiveLimits, resources) {
35601
+ if (effectiveLimits && (effectiveLimits.cpus || effectiveLimits.memBytes)) return effectiveLimits;
35602
+ const limits = resources?.limits;
35603
+ return {
35604
+ ...limits?.memory ? { memBytes: parseMemoryString(limits.memory) } : {},
35605
+ ...limits?.cpus ? { cpus: limits.cpus } : {}
35606
+ };
35607
+ }
35377
35608
  async function createContainerLogCapture(docker, containerId) {
35378
35609
  const stream = await docker.getContainer(containerId).logs({
35379
35610
  follow: true,
@@ -35661,15 +35892,7 @@ var ContainerScalerBackend = class ContainerScalerBackend {
35661
35892
  ];
35662
35893
  const binds = [...matchedLabelSet.volumes ?? []];
35663
35894
  if (matchedLabelSet.containerSocket && this.resolvedSocketPath) binds.push(`${this.resolvedSocketPath}:${this.resolvedSocketPath}`);
35664
- let resolvedLimits;
35665
- if (effectiveLimits && (effectiveLimits.cpus || effectiveLimits.memBytes)) resolvedLimits = effectiveLimits;
35666
- else {
35667
- const limits = (matchedLabelSet.resources ?? this.defaultResources)?.limits;
35668
- resolvedLimits = {
35669
- ...limits?.memory ? { memBytes: parseMemoryString(limits.memory) } : {},
35670
- ...limits?.cpus ? { cpus: limits.cpus } : {}
35671
- };
35672
- }
35895
+ const resolvedLimits = resolveSpawnLimits(effectiveLimits, matchedLabelSet.resources ?? this.defaultResources);
35673
35896
  const jobContainer = spawnContext?.container;
35674
35897
  const spawnImage = jobContainer?.image ?? matchedLabelSet.image;
35675
35898
  await pullImageIfMissing({
@@ -35692,6 +35915,7 @@ var ContainerScalerBackend = class ContainerScalerBackend {
35692
35915
  binds.push(runtimeInjectBind(runtimeVolume));
35693
35916
  agentInit = runtimeInitCommand(await agentImageLabels(this.docker, matchedLabelSet.image));
35694
35917
  }
35918
+ if (!jobContainer) env.push("KICI_SCALER_SINGLE_JOB=1");
35695
35919
  emit(ScalerEventType.enum["scaler.provisioning"], "creating container");
35696
35920
  const normalizedLabelSetStr = normalizeLabelSet(labelSet);
35697
35921
  const container = await this.docker.createContainer({
@@ -35710,7 +35934,8 @@ var ContainerScalerBackend = class ContainerScalerBackend {
35710
35934
  HostConfig: buildAgentContainerHostConfig({
35711
35935
  limits: resolvedLimits,
35712
35936
  binds,
35713
- ...this.extraHosts ? { extraHosts: this.extraHosts } : {}
35937
+ ...this.extraHosts ? { extraHosts: this.extraHosts } : {},
35938
+ ...!jobContainer && { supplementaryGroups: [1990] }
35714
35939
  }),
35715
35940
  ...this.networkIsolation && { NetworkingConfig: { EndpointsConfig: { ["kici-agent-net"]: {} } } }
35716
35941
  });
@@ -36844,7 +37069,7 @@ function routeRootArgs(root, args) {
36844
37069
  * build entry; the fallback covers a source run (tests, `tsx`), the same shape
36845
37070
  * `server.ts` and `standalone.ts` use for `ORCHESTRATOR_VERSION`.
36846
37071
  */
36847
- const CLI_VERSION = "0.18.0";
37072
+ const CLI_VERSION = "0.20.0";
36848
37073
  /**
36849
37074
  * Root command whose option parsing first routes argv through
36850
37075
  * {@link routeRootArgs}: a leading `--version` prints the CLI version, and an
@@ -156,6 +156,8 @@ export declare class DashboardHandler {
156
156
  private enforcePolicy;
157
157
  /** dashboard.run.detail: the run's nested job/step tree. */
158
158
  handleRunDetail(msg: DashboardRunDetailRequest): Promise<void>;
159
+ /** Answer a run-detail read for a run this orchestrator does not hold. */
160
+ private sendRunDetailNotFound;
159
161
  /**
160
162
  * dashboard.run.structured: the user-plane twin of the orchestrator-admin
161
163
  * `/runs/:id/structured` endpoint, byte-identical because it reuses the
@@ -71,7 +71,7 @@ export interface WorkflowDispatchContext {
71
71
  fullLockFile: {
72
72
  workflows: readonly LockWorkflow[];
73
73
  lockfileHash?: string;
74
- /** In-repo `workspace:` sibling closure digest; part of the dep pointer key. */
74
+ /** In-repo workspace sibling closure digest; part of the dep pointer key. */
75
75
  siblingsDigest?: string;
76
76
  source: {
77
77
  file: string;
@@ -193,14 +193,6 @@ export declare function narrowVerdict(raw: unknown, workflowName: string): Globa
193
193
  * best-effort post, so the check would vanish.
194
194
  */
195
195
  export declare const MAX_ROUND_REASON_CHARS = 4000;
196
- /**
197
- * Truncate `text` to at most `max` UTF-16 code units, marking that it was cut.
198
- *
199
- * - A `max` no larger than the marker returns the truncated marker, never a
200
- * string longer than `max`.
201
- * - The cut backs off one unit rather than split a surrogate pair.
202
- */
203
- export declare function truncateReasonText(text: string, max: number): string;
204
196
  /**
205
197
  * One round that produced no verdicts after every attempt. Exactly one per
206
198
  * failed round, never one per candidate (the round exists to collapse N
@@ -2,7 +2,8 @@
2
2
  * Bare-metal scaler backend: ephemeral agent processes via child_process.spawn.
3
3
  * On Linux and macOS each agent leads its own process group, which teardown
4
4
  * signals; on Windows teardown ends the agent's process tree with taskkill.
5
- * All agents are single-use: destroyed after job completion.
5
+ * An agent exits once it is idle past its idle window (a follow-up job that
6
+ * arrives within the window reuses it), and the backend destroys it on disconnect.
6
7
  */
7
8
  import { type ToolRequirement } from '@kici-dev/shared';
8
9
  import type { AgentTokenStore } from '../agent/token-store.js';
@@ -15,6 +15,13 @@ import type { EffectiveLimits } from './types.js';
15
15
  * its own docs and validation, and nothing yet says the constant is wrong.
16
16
  */
17
17
  export declare const AGENT_CONTAINER_PIDS_LIMIT = 4096;
18
+ /**
19
+ * Group id of `kici-sudo` in the KiCI agent image (`packages/agent/Dockerfile`
20
+ * creates it). `/etc/sudoers.d/kici-sudo` grants that group passwordless sudo.
21
+ * The container scaler adds the group only to the single-job containers it
22
+ * starts from a pool's own image, so root never outlives one job.
23
+ */
24
+ export declare const AGENT_SUDO_GROUP_GID = 1990;
18
25
  /**
19
26
  * Why this container is NOT capability-dropped, though the job containers the
20
27
  * agent starts are.
@@ -42,6 +49,8 @@ export interface AgentContainerHostConfigInput {
42
49
  binds?: string[];
43
50
  /** Extra `host:ip` entries (e.g. a private registry alias). */
44
51
  extraHosts?: string[];
52
+ /** Extra group ids for the container's process (`GroupAdd`). */
53
+ supplementaryGroups?: number[];
45
54
  }
46
55
  /**
47
56
  * Build the agent container's `HostConfig`.
@@ -19,7 +19,10 @@ export interface WarmPoolCallbacks {
19
19
  countAvailable: (labels: string[]) => number;
20
20
  /**
21
21
  * Agents this backend spawned that match the label set and have never run a
22
- * job. `registeredAt` doubles as "idle since": scaler agents are single-use.
22
+ * job. `registeredAt` stands in for "idle since". That is exact for a
23
+ * container-backend agent, which exits after its one job. An agent of another
24
+ * backend can finish a job and wait out its idle window before it exits, so it
25
+ * can read as idle for longer than it has been.
23
26
  *
24
27
  * Scoped to the backend because the reaper destroys what it is given; a
25
28
  * matching static agent is ready capacity, not this pool's to tear down.