@kici-dev/orchestrator 0.10.0 → 0.12.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/__test-helpers__/mock-db-query.d.ts +4 -5
- package/dist/agent/agent-version.d.ts +5 -5
- package/dist/agent/dispatcher.d.ts +26 -0
- package/dist/agent/registry.d.ts +7 -9
- package/dist/agent-packaging/node-runtime-members.d.ts +14 -0
- package/dist/app.d.ts +3 -3
- package/dist/approvals/apply-decision.d.ts +1 -1
- package/dist/audit/access-log.d.ts +2 -3
- package/dist/cli/api-client.d.ts +20 -1
- package/dist/cli/commands/access-log.d.ts +3 -3
- package/dist/cli/commands/agent-service/agent-env.d.ts +20 -0
- package/dist/cli/commands/agent-service/status.d.ts +3 -3
- package/dist/cli/commands/attestations.d.ts +10 -0
- package/dist/cli/commands/cold-store-impl.d.ts +3 -3
- package/dist/cli/commands/cold-store.d.ts +6 -3
- package/dist/cli/commands/context.d.ts +5 -3
- package/dist/cli/commands/dashboard-encryption-key.d.ts +18 -0
- package/dist/cli/commands/event-dlq.d.ts +5 -6
- package/dist/cli/commands/event-log.d.ts +2 -3
- package/dist/cli/commands/execution.d.ts +1 -3
- package/dist/cli/commands/orchestrator-service/status.d.ts +10 -3
- package/dist/cli/commands/queue.d.ts +1 -2
- package/dist/cli/commands/registration.d.ts +8 -4
- package/dist/cli/commands/runs.d.ts +2 -3
- package/dist/cli/commands/secret.d.ts +1 -0
- package/dist/cli/commands/service-health.d.ts +68 -0
- package/dist/cli/commands/shared/confirm.d.ts +19 -0
- package/dist/cli/commands/shared/env-file-mode.d.ts +3 -2
- package/dist/cli/commands/shared/upgrade-safety.d.ts +11 -5
- package/dist/cli/commands/shared/versioned-upgrade.d.ts +119 -7
- package/dist/cli/commands/workflow.d.ts +3 -4
- package/dist/cli/open-browser.d.ts +1 -1
- package/dist/cli/service/entrypoint.d.ts +18 -0
- package/dist/cli/service/image-digests.d.ts +10 -0
- package/dist/cli/service/index.d.ts +4 -2
- package/dist/cli/service/instance/manifest.d.ts +39 -0
- package/dist/cli/service/launchd.d.ts +45 -10
- package/dist/cli/service/shutdown-grace.d.ts +35 -0
- package/dist/cli/service/types.d.ts +9 -0
- package/dist/cli/service/windows-acl.d.ts +40 -0
- package/dist/cli/service/windows-env-file.d.ts +45 -0
- package/dist/cli/service/windows.d.ts +13 -0
- package/dist/cli.js +1869 -707
- package/dist/cluster/cluster-identity.d.ts +4 -4
- package/dist/cluster/join-token.d.ts +2 -2
- package/dist/cluster/leader-gated-scheduler.d.ts +7 -1
- package/dist/cluster/peer-credentials.d.ts +1 -1
- package/dist/cluster/raft-state.d.ts +6 -0
- package/dist/cluster/raft.d.ts +16 -2
- package/dist/cold-store/load-access-log-range.d.ts +3 -3
- package/dist/cold-store/load-event-log-range.d.ts +1 -1
- package/dist/cold-store/load-secret-audit-log-range.d.ts +2 -2
- package/dist/cold-store/orchestrator-cold-store.d.ts +9 -11
- package/dist/cold-store/tables/access-log.d.ts +2 -2
- package/dist/cold-store/tables/event-log.d.ts +4 -10
- package/dist/cold-store/tables/execution-jobs.d.ts +1 -1
- package/dist/cold-store/tables/execution-runs.d.ts +4 -4
- package/dist/cold-store/tables/execution-steps.d.ts +1 -1
- package/dist/cold-store/tables/secret-audit-log.d.ts +3 -3
- package/dist/config.d.ts +3 -1
- package/dist/contexts/held-runs.d.ts +2 -2
- package/dist/contexts/variable-store.d.ts +2 -0
- package/dist/dashboard/attestation-filters.d.ts +1 -1
- package/dist/dashboard/handler.d.ts +8 -8
- package/dist/db/deferred-indexes.d.ts +2 -2
- package/dist/db/migrations/005_cold_store_chunk_counter.d.ts +2 -4
- package/dist/db/migrations/006_runs_jobs_steps_archived_at.d.ts +4 -4
- package/dist/db/migrations/007_audit_logs_archived_at.d.ts +6 -9
- package/dist/db/migrations/008_event_log_archived_at.d.ts +4 -6
- package/dist/db/migrations/010_cold_store_chunks.d.ts +2 -2
- package/dist/db/migrations/012_peer_credentials_active_uniq.d.ts +2 -4
- package/dist/db/migrations/016_org_settings_allow_http_npm.d.ts +2 -2
- package/dist/db/migrations/045_host_properties.d.ts +1 -1
- package/dist/db/migrations/046_join_token_consumed_by_instance.d.ts +1 -1
- package/dist/db/migrations/048_host_reboot_pending.d.ts +1 -1
- package/dist/db/migrations/049_held_runs_payload.d.ts +1 -1
- package/dist/db/migrations/050_sources_slug.d.ts +1 -1
- package/dist/db/migrations/051_binding_host_pattern.d.ts +1 -1
- package/dist/db/migrations/052_host_reach_metadata.d.ts +1 -1
- package/dist/db/migrations/053_agent_token_single_use.d.ts +1 -1
- package/dist/db/migrations/054_local_working_tree.d.ts +1 -1
- package/dist/db/migrations/055_agent_token_mandatory_labels.d.ts +1 -1
- package/dist/db/migrations/056_execution_jobs_environments.d.ts +1 -1
- package/dist/db/migrations/064_execution_jobs_skipped_environments.d.ts +1 -1
- package/dist/db/types.d.ts +12 -13
- package/dist/events/types.d.ts +1 -1
- package/dist/fault-injection-types.d.ts +2 -2
- package/dist/firecracker/host-network.d.ts +21 -3
- package/dist/firecracker/net-interfaces.d.ts +1 -1
- package/dist/helpers/run-detached.d.ts +12 -0
- package/dist/index.js +76 -14
- package/dist/oidc/db-signer.d.ts +1 -1
- package/dist/pipeline/global-eval-round.d.ts +2 -2
- package/dist/pipeline/processor.d.ts +2 -2
- package/dist/provenance/retrier-mint.d.ts +1 -1
- package/dist/provenance/trust-root.d.ts +3 -3
- package/dist/providers/universal-git/clone-token.d.ts +9 -13
- package/dist/providers/universal-git/index.d.ts +1 -2
- package/dist/providers/universal-git/normalizer.d.ts +2 -3
- package/dist/providers/universal-git/ssh-auth.d.ts +4 -4
- package/dist/queue/cleanup.d.ts +2 -2
- package/dist/queue/job-queue.d.ts +6 -6
- package/dist/queue/scheduled-job.d.ts +5 -7
- package/dist/registration/extractor.d.ts +1 -1
- package/dist/registration/registration-index.d.ts +1 -1
- package/dist/reporting/execution-tracker.d.ts +3 -3
- package/dist/routes/admin-access-log.d.ts +1 -1
- package/dist/routes/admin-auth.d.ts +2 -2
- package/dist/routes/admin-contexts.d.ts +6 -0
- package/dist/routes/admin-event-log.d.ts +2 -2
- package/dist/routes/admin-registrations.d.ts +2 -2
- package/dist/routes/admin-runs.d.ts +3 -3
- package/dist/routes/admin-scheduled-jobs.d.ts +2 -3
- package/dist/routes/health.d.ts +9 -0
- package/dist/scaler/container-backend.d.ts +1 -1
- package/dist/scaler/firecracker-backend.d.ts +15 -10
- package/dist/scaler/firecracker-extra-hosts.d.ts +20 -0
- package/dist/scaler/machine-ledger.d.ts +3 -4
- package/dist/scaler/scaler-state-store.d.ts +1 -1
- package/dist/scaler/types.d.ts +4 -1
- package/dist/secrets/audit-logger.d.ts +3 -3
- package/dist/security/reduced-privilege-note.d.ts +2 -3
- package/dist/security/trust-policy-store.d.ts +3 -3
- package/dist/server.d.ts +2 -1
- package/dist/server.js +2885 -2211
- package/dist/stale-detector/stale-run-detector.d.ts +1 -1
- package/dist/standalone.d.ts +1 -1
- package/dist/standalone.js +2254 -1629
- package/dist/storage/filesystem.d.ts +1 -1
- package/dist/storage/index.d.ts +1 -1
- package/dist/storage/sign-url.d.ts +2 -1
- package/dist/storage/types.d.ts +2 -2
- package/dist/ws/agent-handler.d.ts +34 -3
- package/dist/ws/agent-heartbeat.d.ts +2 -3
- package/dist/ws/dashboard-context-handler.d.ts +1 -1
- package/dist/ws/platform-client.d.ts +44 -32
- package/dist/ws/server-options.d.ts +1 -1
- package/dist/ws/test-relay-handlers.d.ts +1 -1
- package/installer-image-digests.json +3 -3
- package/package.json +4 -4
- package/sbom.spdx.json +53 -53
package/dist/cli.js
CHANGED
|
@@ -6,7 +6,7 @@ import path, { dirname, join, resolve } from "node:path";
|
|
|
6
6
|
import * as fs$1 from "node:fs";
|
|
7
7
|
import fs, { chmodSync, closeSync, constants, createReadStream, createWriteStream, existsSync, mkdirSync, mkdtempSync, openSync, promises, readFileSync, realpathSync, rmSync, statSync, unwatchFile, watchFile, writeFileSync } from "node:fs";
|
|
8
8
|
import { Command, Option } from "commander";
|
|
9
|
-
import { AgentPlatform, BaseColdStore, ChunkLru, addLogsToArchive, chunkObjectKey, clearDispatchQueueDirect, computeChunkId, computeMigrationsHash, createContextTemplateDirect, createDb, createDbRole, createLogger, createPool, createReadOnlyDbUser, createS3Client, decodeChunk, decrypt, deleteContextDirect, deriveKey, diagnoseExitCode, dropAndCreateDatabase, emitKiciEventDirect, encodeKeySegment, encrypt, ensureDatabase, formatBytes, formatUptime, isSchemaCurrent, kiciMkdtemp, listCheckRunTrackingDirect, listContextsDirect, listExecutionRunsDirect, listQueueDirect, listRegistrationsDirect, maskDatabaseUrl, parseDatabaseUrl, parseManifest, prunePeerCredentialsDirect, purgeContextsDirect, purgeScopedSecretsDirect, purgeSecretBackendsDirect, purgeStaleExecutionDirect, purgeStaleSourcesDirect, registerWorkflowManualDirect, resetRaftStateDirect, seedContextBindingDirect, seedContextDirect, serializeError, setContextPolicyDirect, setContextSecretDirect, sha256, showContextDirect, showExecutionRunDirect, showQueueEntryDirect, showRegistrationDirect, splitAgentPlatform, storeMigrationContentHash, tablePrefix, toErrorMessage } from "@kici-dev/shared";
|
|
9
|
+
import { AgentPlatform, BaseColdStore, ChunkLru, ReadinessStatus, addLogsToArchive, chunkObjectKey, clearDispatchQueueDirect, computeChunkId, computeMigrationsHash, createContextTemplateDirect, createDb, createDbRole, createLogger, createPool, createReadOnlyDbUser, createS3Client, decodeChunk, decrypt, deleteContextDirect, deriveKey, diagnoseExitCode, dropAndCreateDatabase, emitKiciEventDirect, encodeKeySegment, encrypt, ensureDatabase, formatBytes, formatUptime, isSchemaCurrent, kiciMkdtemp, listCheckRunTrackingDirect, listContextsDirect, listExecutionRunsDirect, listQueueDirect, listRegistrationsDirect, maskDatabaseUrl, parseDatabaseUrl, parseManifest, prunePeerCredentialsDirect, purgeContextsDirect, purgeScopedSecretsDirect, purgeSecretBackendsDirect, purgeStaleExecutionDirect, purgeStaleSourcesDirect, registerWorkflowManualDirect, resetRaftStateDirect, seedContextBindingDirect, seedContextDirect, serializeError, setContextPolicyDirect, setContextSecretDirect, sha256, showContextDirect, showExecutionRunDirect, showQueueEntryDirect, showRegistrationDirect, splitAgentPlatform, storeMigrationContentHash, tablePrefix, toErrorMessage } from "@kici-dev/shared";
|
|
10
10
|
import { AccessLogSource, ApprovalDecision, CiTrustLevel, ContextType, ExecutionJobStatus, ForkPolicy, HoldType, KICI_AGENT_ENV_PREFIX, KNOWN_ROLES, MIN_APPROVAL_EXPIRY_SECONDS, MinimumTrustSchema, OrchestratorMode, PLATFORM_CONNECTED_MODES, PRIVILEGED_ROOT_LABEL, RuntimeFact, SECONDS_PER_HOUR, ScalerBackendType, ScalerEventType, TERMINAL_JOB_STATES, TERMINAL_RUN_STATES, WS_MAX_PAYLOAD_BYTES, accessLogWarmSqlCase, agentLabelOf, assertValidSecretKey, attestationVerifyStatusSchema, canonicalizeLabel, canonicalizeLabels, canonicalizeMatcher, derivePlatformTaints, flattenActor, getAccessLogColdDays, getSecretAuditLogColdDays, matcherSatisfiedBy, minAccessLogWarmDays, minSecretAuditLogWarmDays, parseHostPropertyAssignments, platformToTaints, resolveHeldRunId, resolveRoleLabels, runtimeLabel, scalerAgentLabels, scalerPlatformSchema, secretAuditLogWarmSqlCase, shouldRecordAccess, shouldRecordSecretResolve } from "@kici-dev/engine";
|
|
11
11
|
import crypto$1, { createDecipheriv, createHash, createHmac, createPublicKey, generateKeyPairSync, hkdfSync, randomBytes, randomUUID } from "node:crypto";
|
|
12
12
|
import { createInterface } from "node:readline";
|
|
@@ -14,17 +14,18 @@ import { sql } from "kysely";
|
|
|
14
14
|
import { access, chmod, constants as constants$1, copyFile, link, mkdir, open, readFile, readdir, readlink, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
15
15
|
import { parse } from "yaml";
|
|
16
16
|
import { z } from "zod";
|
|
17
|
+
import { AGENT_DEFAULT_PORT, LOGGER_ENV_VARS, ORCHESTRATOR_DEFAULT_PORT, defineEnv, validateUnknownKiciVars } from "@kici-dev/shared/env";
|
|
17
18
|
import os, { hostname, platform, tmpdir, uptime, default as os$1 } from "node:os";
|
|
18
19
|
import { getDatabaseCollationDrift, refreshDatabaseCollationVersion, reindexDatabaseConcurrently } from "@kici-dev/shared/db-collation";
|
|
19
20
|
import { Migrator } from "kysely/migration";
|
|
20
21
|
import { exec, execFile, execFileSync, execSync, spawn, spawnSync } from "node:child_process";
|
|
21
22
|
import { promisify } from "node:util";
|
|
23
|
+
import { SERVICE_ENV_FILE_VAR } from "@kici-dev/shared/service-env-file";
|
|
22
24
|
import "jsonpath-plus";
|
|
23
25
|
import { makeTempDir, withTempDir } from "@kici-dev/shared/tmp";
|
|
24
26
|
import { Octokit } from "@octokit/rest";
|
|
25
27
|
import { createAppAuth } from "@octokit/auth-app";
|
|
26
28
|
import http, { createServer } from "node:http";
|
|
27
|
-
import { LOGGER_ENV_VARS, defineEnv, validateUnknownKiciVars } from "@kici-dev/shared/env";
|
|
28
29
|
import { CopyObjectCommand, DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, ListObjectsV2Command, PutObjectCommand } from "@aws-sdk/client-s3";
|
|
29
30
|
import { Upload } from "@aws-sdk/lib-storage";
|
|
30
31
|
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
|
@@ -43,6 +44,7 @@ import { CLI_INDEPENDENT_ONLY_OPERATIONS, DASHBOARD_WRITE_OPERATIONS, DASHBOARD_
|
|
|
43
44
|
import { CLUSTER_NAME_FORMAT_MESSAGE } from "@kici-dev/engine/protocol/cluster-name";
|
|
44
45
|
import { BASELINE_CHAIN, JOB_NETWORK_SUBNET, METADATA_RANGE, RFC1918_RANGES, addHostIsolationRules, addIsolationRules, deleteForwardRules, ensureKiciTable, listForwardRules, listIsolationRules, parseHostAccess, parseRuleHandles, readForwardChain, removeIsolationRules, validateNftablesAvailability } from "@kici-dev/shared/net";
|
|
45
46
|
import { ImagePullPolicy, ensureRuntimeVolume, injectedAgentCommand, pullImageIfMissing, runtimeInjectBind } from "@kici-dev/shared/container-runtime";
|
|
47
|
+
import { isIP } from "node:net";
|
|
46
48
|
import { StringDecoder } from "node:string_decoder";
|
|
47
49
|
import { PassThrough } from "node:stream";
|
|
48
50
|
import Docker from "dockerode";
|
|
@@ -254,6 +256,32 @@ var AdminApiClient = class {
|
|
|
254
256
|
return this.request("DELETE", `/api/v1/admin/secrets/${encodeURIComponent(orgId)}/${encodeURIComponent(scope)}/${encodeURIComponent(key)}`);
|
|
255
257
|
}
|
|
256
258
|
/**
|
|
259
|
+
* Create an empty secret scope. A `<backend>:` qualifier selects the
|
|
260
|
+
* backend; an unqualified scope targets the PG backend. Creating a scope
|
|
261
|
+
* that already exists succeeds and changes nothing.
|
|
262
|
+
*/
|
|
263
|
+
async createScope(orgId, scope) {
|
|
264
|
+
return this.request("POST", "/api/v1/admin/secrets/scopes", {
|
|
265
|
+
orgId,
|
|
266
|
+
scope
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* Rename a secret scope inside its backend. The route refuses a move between
|
|
271
|
+
* backends and a rename onto a scope that already exists.
|
|
272
|
+
*/
|
|
273
|
+
async renameScope(orgId, oldScope, newScope) {
|
|
274
|
+
return this.request("PUT", "/api/v1/admin/secrets/scopes/rename", {
|
|
275
|
+
orgId,
|
|
276
|
+
oldScope,
|
|
277
|
+
newScope
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
/** Delete a secret scope and every secret in it. */
|
|
281
|
+
async deleteScope(orgId, scope) {
|
|
282
|
+
return this.request("DELETE", `/api/v1/admin/secrets/scopes/${encodeURIComponent(orgId)}/${encodeURIComponent(scope)}`);
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
257
285
|
* Create (or upsert) a deployment context. `allowLocalExecution` lets
|
|
258
286
|
* CLI-initiated / local runs resolve secrets through this context.
|
|
259
287
|
*/
|
|
@@ -999,13 +1027,13 @@ var PgSecretStore = class PgSecretStore {
|
|
|
999
1027
|
//#endregion
|
|
1000
1028
|
//#region src/cold-store/load-access-log-range.ts
|
|
1001
1029
|
/**
|
|
1002
|
-
* Read-through helper for `access_log` (Orchestrator)
|
|
1030
|
+
* Read-through helper for `access_log` (Orchestrator).
|
|
1003
1031
|
*
|
|
1004
1032
|
* Unlike `audit_log` and `secret_audit_log`, the `access_log` callers
|
|
1005
1033
|
* (dashboard "Data access" tab, admin HTTP route, `kici-admin
|
|
1006
1034
|
* access-log list` CLI) all use the same cursor-based pagination
|
|
1007
|
-
* already implemented by `AccessLogWriter.query()`.
|
|
1008
|
-
*
|
|
1035
|
+
* already implemented by `AccessLogWriter.query()`. access_log is
|
|
1036
|
+
* "paginated/transparent" — there
|
|
1009
1037
|
* is NO `--include-archived` flag. The helper merges cold rows
|
|
1010
1038
|
* automatically when pagination crosses the warm cutoff or the caller
|
|
1011
1039
|
* supplies a `from` timestamp older than warm.
|
|
@@ -1185,9 +1213,9 @@ var init_load_access_log_range = __esmMin((() => {
|
|
|
1185
1213
|
//#endregion
|
|
1186
1214
|
//#region src/cold-store/load-secret-audit-log-range.ts
|
|
1187
1215
|
/**
|
|
1188
|
-
* Read-through helper for `secret_audit_log` (Orchestrator)
|
|
1216
|
+
* Read-through helper for `secret_audit_log` (Orchestrator).
|
|
1189
1217
|
*
|
|
1190
|
-
* Mirrors the
|
|
1218
|
+
* Mirrors the execution-table reader pattern. Wraps `AuditLogger.query()`'s
|
|
1191
1219
|
* existing offset/limit shape; cold-store rows are fetched only when
|
|
1192
1220
|
* `includeArchived === true`.
|
|
1193
1221
|
*
|
|
@@ -1284,7 +1312,7 @@ var AuditLogger = class {
|
|
|
1284
1312
|
* engine's `shouldRecordSecretResolve` helper) since they're written
|
|
1285
1313
|
* once per JOB-step secret resolution and dwarf every other action's
|
|
1286
1314
|
* volume. Denied resolves and every other action (`setSecret`,
|
|
1287
|
-
* `deleteSecret`, `rotateKey`, `secret-outputs.reveal`) bypass the
|
|
1315
|
+
* `deleteSecret`, the scope writes, `rotateKey`, `secret-outputs.reveal`) bypass the
|
|
1288
1316
|
* sampler — they are transactional with the mutation and must always
|
|
1289
1317
|
* land.
|
|
1290
1318
|
*
|
|
@@ -1494,6 +1522,48 @@ async function warnIfContextUnbound(args) {
|
|
|
1494
1522
|
if (found && type?.success && found.bindings.length === 0) (args.warn ?? console.error)(unboundContextWarning(args.orgId, args.name, type.data));
|
|
1495
1523
|
}
|
|
1496
1524
|
//#endregion
|
|
1525
|
+
//#region src/cli/commands/shared/confirm.ts
|
|
1526
|
+
/**
|
|
1527
|
+
* Yes/no confirmation prompt for the `kici-admin` verbs that change or destroy
|
|
1528
|
+
* state unless the operator passes `--yes`.
|
|
1529
|
+
*/
|
|
1530
|
+
/** Message of the error raised when stdin ends before the operator answers. */
|
|
1531
|
+
const NO_CONFIRMATION_ANSWER = "no answer to the confirmation prompt: stdin closed before a line arrived. Pass --yes to run without the prompt.";
|
|
1532
|
+
/**
|
|
1533
|
+
* Write `prompt` and read one line of answer.
|
|
1534
|
+
*
|
|
1535
|
+
* Resolves true when the line is `y` or `yes` (any case, surrounding spaces
|
|
1536
|
+
* ignored) and false for any other line. Rejects with
|
|
1537
|
+
* {@link NO_CONFIRMATION_ANSWER} when stdin ends before a line arrives, as it
|
|
1538
|
+
* does for a script, a cron job or a `< /dev/null` redirect. Resolving false
|
|
1539
|
+
* there would let a verb run without `--yes` exit 0 having done nothing, and
|
|
1540
|
+
* the script that ran it would read that as success.
|
|
1541
|
+
*/
|
|
1542
|
+
function confirmPrompt(prompt, io = {
|
|
1543
|
+
input: process.stdin,
|
|
1544
|
+
output: process.stderr
|
|
1545
|
+
}) {
|
|
1546
|
+
const rl = createInterface({
|
|
1547
|
+
input: io.input,
|
|
1548
|
+
output: io.output
|
|
1549
|
+
});
|
|
1550
|
+
return new Promise((resolve, reject) => {
|
|
1551
|
+
let answered = false;
|
|
1552
|
+
const settle = (answer) => {
|
|
1553
|
+
if (answered) return;
|
|
1554
|
+
answered = true;
|
|
1555
|
+
rl.close();
|
|
1556
|
+
const normalized = answer.trim().toLowerCase();
|
|
1557
|
+
resolve(normalized === "y" || normalized === "yes");
|
|
1558
|
+
};
|
|
1559
|
+
rl.once("close", () => {
|
|
1560
|
+
if (!answered) reject(/* @__PURE__ */ new Error(NO_CONFIRMATION_ANSWER));
|
|
1561
|
+
});
|
|
1562
|
+
rl.once("line", settle);
|
|
1563
|
+
rl.question(prompt, settle);
|
|
1564
|
+
});
|
|
1565
|
+
}
|
|
1566
|
+
//#endregion
|
|
1497
1567
|
//#region src/cli/commands/secret.ts
|
|
1498
1568
|
function resolveDirectDbUrl$10(explicit) {
|
|
1499
1569
|
return explicit ?? process.env.KICI_DATABASE_URL ?? null;
|
|
@@ -1658,7 +1728,7 @@ function registerSecretCommands(program, getClient) {
|
|
|
1658
1728
|
sec.command("delete <orgId> <scope> <key>").description("Delete a secret").option("--yes", "Skip confirmation prompt").action(async (orgId, scope, key, opts) => {
|
|
1659
1729
|
try {
|
|
1660
1730
|
if (!opts.yes) {
|
|
1661
|
-
if (!await
|
|
1731
|
+
if (!await confirmPrompt(`Are you sure you want to delete secret '${key}' from scope '${scope}'? [y/N] `)) {
|
|
1662
1732
|
console.log("Aborted.");
|
|
1663
1733
|
return;
|
|
1664
1734
|
}
|
|
@@ -1670,6 +1740,7 @@ function registerSecretCommands(program, getClient) {
|
|
|
1670
1740
|
process.exit(1);
|
|
1671
1741
|
}
|
|
1672
1742
|
});
|
|
1743
|
+
registerSecretScopeCommands(sec, getClient);
|
|
1673
1744
|
sec.command("fix-prefixed-scopes <orgId>").description("Repair PG-backend secret scopes stored with a stale pg: qualifier (direct-DB). Each affected scope is renamed to its bare path, re-encrypting every secret under the corrected AAD. Exits 2 when any scope needs manual repair.").option("--dry-run", "Print the plan and exit without writing").option("--database-url <url>", "Orchestrator DB URL (or KICI_DATABASE_URL)").action(async (orgId, opts) => {
|
|
1674
1745
|
const dbUrl = resolveDirectDbUrl$10(opts.databaseUrl);
|
|
1675
1746
|
if (!dbUrl) {
|
|
@@ -1720,17 +1791,48 @@ function registerSecretCommands(program, getClient) {
|
|
|
1720
1791
|
if (skipped > 0) process.exit(2);
|
|
1721
1792
|
});
|
|
1722
1793
|
}
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1794
|
+
/**
|
|
1795
|
+
* `secret scope create|rename|delete` — the operator path for the dashboard's
|
|
1796
|
+
* scope writes (`secrets.scope.*` in the dashboard-write policy). HTTP only,
|
|
1797
|
+
* like `secret scopes`: each verb calls the orchestrator admin API.
|
|
1798
|
+
*/
|
|
1799
|
+
function registerSecretScopeCommands(sec, getClient) {
|
|
1800
|
+
const scope = sec.command("scope").description("Create, rename or delete a secret scope");
|
|
1801
|
+
scope.command("create <orgId> <scope>").description("Create an empty secret scope. A <backend>: qualifier selects the backend; an unqualified scope targets the PG backend. An existing scope stays unchanged.").option("--json", "Emit JSON output").action(async (orgId, scopeName, opts) => {
|
|
1802
|
+
try {
|
|
1803
|
+
const result = await getClient().createScope(orgId, scopeName);
|
|
1804
|
+
if (opts.json) console.log(JSON.stringify(result));
|
|
1805
|
+
else console.log(`Secret scope '${scopeName}' created for org ${orgId}.`);
|
|
1806
|
+
} catch (err) {
|
|
1807
|
+
console.error(`Error: ${toErrorMessage(err)}`);
|
|
1808
|
+
process.exit(1);
|
|
1809
|
+
}
|
|
1728
1810
|
});
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1811
|
+
scope.command("rename <orgId> <oldScope> <newScope>").description("Rename a secret scope inside its backend. Refuses a move between backends and a rename onto a scope that already exists.").option("--json", "Emit JSON output").action(async (orgId, oldScope, newScope, opts) => {
|
|
1812
|
+
try {
|
|
1813
|
+
const result = await getClient().renameScope(orgId, oldScope, newScope);
|
|
1814
|
+
if (opts.json) console.log(JSON.stringify(result));
|
|
1815
|
+
else console.log(`Secret scope '${oldScope}' renamed to '${newScope}' for org ${orgId}.`);
|
|
1816
|
+
} catch (err) {
|
|
1817
|
+
console.error(`Error: ${toErrorMessage(err)}`);
|
|
1818
|
+
process.exit(1);
|
|
1819
|
+
}
|
|
1820
|
+
});
|
|
1821
|
+
scope.command("delete <orgId> <scope>").description("Delete a secret scope and every secret in it").option("--yes", "Skip confirmation prompt").option("--json", "Emit JSON output").action(async (orgId, scopeName, opts) => {
|
|
1822
|
+
try {
|
|
1823
|
+
if (!opts.yes) {
|
|
1824
|
+
if (!await confirmPrompt(`Are you sure you want to delete scope '${scopeName}' and every secret in it? [y/N] `)) {
|
|
1825
|
+
console.log("Aborted.");
|
|
1826
|
+
return;
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
const result = await getClient().deleteScope(orgId, scopeName);
|
|
1830
|
+
if (opts.json) console.log(JSON.stringify(result));
|
|
1831
|
+
else console.log(`Secret scope '${scopeName}' deleted for org ${orgId}.`);
|
|
1832
|
+
} catch (err) {
|
|
1833
|
+
console.error(`Error: ${toErrorMessage(err)}`);
|
|
1834
|
+
process.exit(1);
|
|
1835
|
+
}
|
|
1734
1836
|
});
|
|
1735
1837
|
}
|
|
1736
1838
|
//#endregion
|
|
@@ -1808,7 +1910,7 @@ function formatAuditTable(entries) {
|
|
|
1808
1910
|
].join("\n");
|
|
1809
1911
|
}
|
|
1810
1912
|
function registerAuditCommands(program, getClient) {
|
|
1811
|
-
program.command("audit").description("Query the secrets audit log").option("--context <name>", "Filter by context name").option("--routing-key <rk>", "Filter by routing key (required for cold-store scan)").option("--action <action>", "Filter by action type").option("--from <date>", "From date (ISO 8601)").option("--to <date>", "To date (ISO 8601)").option("--limit <n>", "Max entries to return", "100").option("--offset <n>", "Offset for pagination").option("--include-archived", "Include rows from cold storage
|
|
1913
|
+
program.command("audit").description("Query the secrets audit log").option("--context <name>", "Filter by context name").option("--routing-key <rk>", "Filter by routing key (required for cold-store scan)").option("--action <action>", "Filter by action type").option("--from <date>", "From date (ISO 8601)").option("--to <date>", "To date (ISO 8601)").option("--limit <n>", "Max entries to return", "100").option("--offset <n>", "Offset for pagination").option("--include-archived", "Include rows from cold storage", false).action(async (opts) => {
|
|
1812
1914
|
try {
|
|
1813
1915
|
const entries = await getClient().queryAudit({
|
|
1814
1916
|
contextName: opts.context,
|
|
@@ -2206,7 +2308,7 @@ var init_schema = __esmMin((() => {
|
|
|
2206
2308
|
instanceId: z.string().default(() => randomUUID()),
|
|
2207
2309
|
mode: OrchestratorMode.default("platform"),
|
|
2208
2310
|
databaseUrl: z.string().default(""),
|
|
2209
|
-
port: z.coerce.number().default(
|
|
2311
|
+
port: z.coerce.number().default(ORCHESTRATOR_DEFAULT_PORT),
|
|
2210
2312
|
basePath: z.string().default("/"),
|
|
2211
2313
|
tlsCertPath: z.string().optional(),
|
|
2212
2314
|
scalerConfigPath: z.string().optional(),
|
|
@@ -3643,12 +3745,10 @@ var _005_cold_store_chunk_counter_exports = /* @__PURE__ */ __exportAll({
|
|
|
3643
3745
|
* `kici-admin cold-store list-chunks` CLI can report totals without
|
|
3644
3746
|
* issuing S3 LIST calls (expensive + rate-limited).
|
|
3645
3747
|
*
|
|
3646
|
-
*
|
|
3647
|
-
*
|
|
3748
|
+
* The table starts empty; the cold-store archiver
|
|
3749
|
+
* updates it transactionally with every successful chunk
|
|
3648
3750
|
* write via `INSERT ... ON CONFLICT (db, table_name, tenant_id) DO
|
|
3649
3751
|
* UPDATE SET chunk_count = chunk_count + EXCLUDED.chunk_count, ...`.
|
|
3650
|
-
*
|
|
3651
|
-
* sections 5 and 8.
|
|
3652
3752
|
*/
|
|
3653
3753
|
async function up$146(db) {
|
|
3654
3754
|
await sql`
|
|
@@ -3681,12 +3781,12 @@ var _006_runs_jobs_steps_archived_at_exports = /* @__PURE__ */ __exportAll({
|
|
|
3681
3781
|
* `execution_runs` / `execution_jobs` / `execution_steps` cold-store
|
|
3682
3782
|
* schema additions.
|
|
3683
3783
|
*
|
|
3684
|
-
*
|
|
3784
|
+
* Part of the cold-storage archival system.
|
|
3685
3785
|
*
|
|
3686
3786
|
* Each table gets `archived_at TIMESTAMPTZ NULL` and
|
|
3687
3787
|
* `archive_object_key TEXT NULL` (set inside the archive tx before the
|
|
3688
|
-
* DELETE; survivors carry NULL — same pattern
|
|
3689
|
-
*
|
|
3788
|
+
* DELETE; survivors carry NULL — the same pattern `run_events` carries
|
|
3789
|
+
* in the Platform's schema).
|
|
3690
3790
|
*
|
|
3691
3791
|
* Tenant column denormalization: the orchestrator side partitions by
|
|
3692
3792
|
* `routing_key`, but only `execution_runs` carries the column today.
|
|
@@ -3695,7 +3795,7 @@ var _006_runs_jobs_steps_archived_at_exports = /* @__PURE__ */ __exportAll({
|
|
|
3695
3795
|
* denormalize `routing_key` onto both tables and backfill from the
|
|
3696
3796
|
* existing run rows. Backfill is one-shot inside this migration. New
|
|
3697
3797
|
* inserts must populate the column going forward (enforced at the
|
|
3698
|
-
* application layer
|
|
3798
|
+
* application layer).
|
|
3699
3799
|
*
|
|
3700
3800
|
* The denormalized column is left NULLable for safety: if an insert
|
|
3701
3801
|
* site is missed in the rollout, the row still lands in PG; the
|
|
@@ -3788,13 +3888,12 @@ var _007_audit_logs_archived_at_exports = /* @__PURE__ */ __exportAll({
|
|
|
3788
3888
|
* `secret_audit_log` and `access_log` cold-store schema additions, plus
|
|
3789
3889
|
* the removal of `access_log`'s `expires_at`-based hard delete.
|
|
3790
3890
|
*
|
|
3791
|
-
*
|
|
3891
|
+
* Part of the cold-storage archival system.
|
|
3792
3892
|
*
|
|
3793
3893
|
* Both tables get the same `archived_at TIMESTAMPTZ NULL` /
|
|
3794
|
-
* `archive_object_key TEXT NULL` pair that
|
|
3795
|
-
* (`
|
|
3796
|
-
*
|
|
3797
|
-
* `execution_steps` (`006_runs_jobs_steps_archived_at.ts`). Set inside
|
|
3894
|
+
* `archive_object_key TEXT NULL` pair that `run_events` carries
|
|
3895
|
+
* (in the Platform's schema) and that `execution_runs` / `execution_jobs` /
|
|
3896
|
+
* `execution_steps` got in `006_runs_jobs_steps_archived_at.ts`. Set inside
|
|
3798
3897
|
* the archive transaction before the DELETE; survivors carry NULL.
|
|
3799
3898
|
*
|
|
3800
3899
|
* `secret_audit_log` gets a composite
|
|
@@ -3808,7 +3907,7 @@ var _007_audit_logs_archived_at_exports = /* @__PURE__ */ __exportAll({
|
|
|
3808
3907
|
* (range scans use either direction).
|
|
3809
3908
|
*
|
|
3810
3909
|
* Drops `access_log.expires_at` column and `access_log_expires_idx`.
|
|
3811
|
-
*
|
|
3910
|
+
* Before this migration, `expires_at` powered a 90-day hard-delete sweep in
|
|
3812
3911
|
* `packages/orchestrator/src/audit/access-log.ts:cleanup` (called from
|
|
3813
3912
|
* `packages/orchestrator/src/queue/cleanup.ts:runCleanup` step 5). With
|
|
3814
3913
|
* cold-store, `access_log` rows older than 30 days are archived rather
|
|
@@ -3816,11 +3915,9 @@ var _007_audit_logs_archived_at_exports = /* @__PURE__ */ __exportAll({
|
|
|
3816
3915
|
* column would mean either (a) double-evicting rows or (b) silently
|
|
3817
3916
|
* mismatching the new contract. Drop it.
|
|
3818
3917
|
*
|
|
3819
|
-
* Per the project's "no backward compatibility (pre-release)" rule,
|
|
3820
3918
|
* `down()` recreates `expires_at` with a default of `now() + 90 days`
|
|
3821
3919
|
* — best effort; rows inserted between `up()` and a hypothetical
|
|
3822
|
-
* `down()` would not have meaningful retention bounds.
|
|
3823
|
-
* staging.
|
|
3920
|
+
* `down()` would not have meaningful retention bounds.
|
|
3824
3921
|
*/
|
|
3825
3922
|
async function up$144(db) {
|
|
3826
3923
|
await sql`
|
|
@@ -3878,13 +3975,11 @@ var _008_event_log_archived_at_exports = /* @__PURE__ */ __exportAll({
|
|
|
3878
3975
|
* `event_log` cold-store schema additions plus removal of the
|
|
3879
3976
|
* `expires_at`-based 30-day hard delete.
|
|
3880
3977
|
*
|
|
3881
|
-
*
|
|
3882
|
-
* §10
|
|
3883
|
-
* Phase E.
|
|
3978
|
+
* Part of the cold-storage archival system.
|
|
3884
3979
|
*
|
|
3885
3980
|
* Adds the same `archived_at TIMESTAMPTZ NULL` /
|
|
3886
|
-
* `archive_object_key TEXT NULL` pair
|
|
3887
|
-
*
|
|
3981
|
+
* `archive_object_key TEXT NULL` pair
|
|
3982
|
+
* `007_audit_logs_archived_at.ts` added on this side. Set inside the archive
|
|
3888
3983
|
* transaction before the DELETE; survivors carry NULL.
|
|
3889
3984
|
*
|
|
3890
3985
|
* Adds `idx_event_log_routing_received` on `(routing_key, received_at)`
|
|
@@ -3895,7 +3990,7 @@ var _008_event_log_archived_at_exports = /* @__PURE__ */ __exportAll({
|
|
|
3895
3990
|
* archiver scan.
|
|
3896
3991
|
*
|
|
3897
3992
|
* Drops `event_log.expires_at` and the matching
|
|
3898
|
-
* `event_log_expires_at_idx` index.
|
|
3993
|
+
* `event_log_expires_at_idx` index. Before this migration, `expires_at`
|
|
3899
3994
|
* powered the 30-day hard-delete sweep in
|
|
3900
3995
|
* `packages/orchestrator/src/webhook/event-log.ts:cleanup`, called
|
|
3901
3996
|
* from `packages/orchestrator/src/queue/cleanup.ts:runCleanup` step 4
|
|
@@ -3978,7 +4073,7 @@ var _010_cold_store_chunks_exports = /* @__PURE__ */ __exportAll({
|
|
|
3978
4073
|
up: () => up$141
|
|
3979
4074
|
});
|
|
3980
4075
|
/**
|
|
3981
|
-
* Cold-store chunk index
|
|
4076
|
+
* Cold-store chunk index (cold-store purge).
|
|
3982
4077
|
*
|
|
3983
4078
|
* One row per archived chunk that the GC sweep can later purge from S3.
|
|
3984
4079
|
* Inserted inside the same transaction as `markArchivedAndDelete` so a
|
|
@@ -4003,7 +4098,7 @@ var _010_cold_store_chunks_exports = /* @__PURE__ */ __exportAll({
|
|
|
4003
4098
|
* uses this to issue `DeleteObject` directly without recomputing the
|
|
4004
4099
|
* key from the prefix.
|
|
4005
4100
|
*
|
|
4006
|
-
*
|
|
4101
|
+
* V1-manifest chunks are NOT in this table — they live
|
|
4007
4102
|
* forever. Adapters that don't opt into per-bucket archival via
|
|
4008
4103
|
* `coldTtlDays` don't insert here either.
|
|
4009
4104
|
*/
|
|
@@ -4119,13 +4214,11 @@ var _012_peer_credentials_active_uniq_exports = /* @__PURE__ */ __exportAll({
|
|
|
4119
4214
|
* nothing and both `INSERT`s succeeded, leaving 2+ ACTIVE rows for the same
|
|
4120
4215
|
* `instance_id`. The peer's local credential file then ended up holding a
|
|
4121
4216
|
* hash that a later concurrent `save()` had revoked, causing an endless
|
|
4122
|
-
* "Peer HMAC proof invalid" loop.
|
|
4123
|
-
* for the
|
|
4124
|
-
* full incident write-up (DB rows + log evidence from real staging deploys).
|
|
4217
|
+
* "Peer HMAC proof invalid" loop.
|
|
4125
4218
|
*
|
|
4126
4219
|
* The `up()` first dedupes any pre-existing duplicates (revoke all but the
|
|
4127
4220
|
* newest active row per `instance_id`) — required because earlier migrations
|
|
4128
|
-
* predate the constraint and existing
|
|
4221
|
+
* predate the constraint and existing databases may already hold the dupes.
|
|
4129
4222
|
*
|
|
4130
4223
|
* `down()` only drops the index; it does NOT undo the dedupe (there's no
|
|
4131
4224
|
* safe way to recreate revoked rows, and the dedupe is monotonic).
|
|
@@ -4463,8 +4556,8 @@ var _016_org_settings_allow_http_npm_exports = /* @__PURE__ */ __exportAll({
|
|
|
4463
4556
|
* Operators flip this on a per-org basis to permit `http://` (non-HTTPS)
|
|
4464
4557
|
* registry URLs in workflow `registries:` declarations. By default only
|
|
4465
4558
|
* `https://` registries are accepted, with an automatic exemption for
|
|
4466
|
-
* loopback / `*.local` / private link-local hosts so
|
|
4467
|
-
* (
|
|
4559
|
+
* loopback / `*.local` / private link-local hosts so a local registry
|
|
4560
|
+
* (for example `http://registry.local:4873`) keeps working.
|
|
4468
4561
|
*
|
|
4469
4562
|
* Idempotent: a re-run on a DB that already has the column is a no-op.
|
|
4470
4563
|
*/
|
|
@@ -4509,8 +4602,7 @@ var _017_org_id_widen_exports = /* @__PURE__ */ __exportAll({
|
|
|
4509
4602
|
* They are orphans by design — once Platform's migration runs, the
|
|
4510
4603
|
* dashboard queries the orchestrator API with the new prefixed IDs
|
|
4511
4604
|
* and the orchestrator's `WHERE org_id = $1` filter naturally hides
|
|
4512
|
-
* the old rows.
|
|
4513
|
-
* test run.
|
|
4605
|
+
* the old rows. New activity writes fresh rows under the prefixed IDs.
|
|
4514
4606
|
*
|
|
4515
4607
|
* Tables widened: `access_log`, `environment_bindings`,
|
|
4516
4608
|
* `environment_source_overrides`, `environment_variables`,
|
|
@@ -4552,7 +4644,7 @@ var _018_org_id_prefix_backfill_exports = /* @__PURE__ */ __exportAll({
|
|
|
4552
4644
|
* orphaned and regenerated on the next deploy. That works for
|
|
4553
4645
|
* historical artefacts (execution_runs, event_log, access_log) but
|
|
4554
4646
|
* NOT for tenant-state tables the orchestrator needs to recognise the
|
|
4555
|
-
*
|
|
4647
|
+
* tenant's org at routing time (`sources`, `generic_webhook_sources`,
|
|
4556
4648
|
* `workflow_registrations`, `org_settings`) and the env-config tables
|
|
4557
4649
|
* referenced from runtime requests (`environments`,
|
|
4558
4650
|
* `environment_bindings`, `environment_source_overrides`,
|
|
@@ -5807,7 +5899,7 @@ var _045_host_properties_exports = /* @__PURE__ */ __exportAll({
|
|
|
5807
5899
|
* the separate queryable host-vars dimension.
|
|
5808
5900
|
*
|
|
5809
5901
|
* Idempotent (`ADD COLUMN IF NOT EXISTS`): re-running on a DB that already has
|
|
5810
|
-
* the column is a no-op.
|
|
5902
|
+
* the column is a no-op. Existing data is preserved (additive column).
|
|
5811
5903
|
*/
|
|
5812
5904
|
async function up$106(db) {
|
|
5813
5905
|
await sql`ALTER TABLE public.host_roster
|
|
@@ -5833,7 +5925,7 @@ var _046_join_token_consumed_by_instance_exports = /* @__PURE__ */ __exportAll({
|
|
|
5833
5925
|
* join token already in its env — no operator action, no cluster redeploy.
|
|
5834
5926
|
*
|
|
5835
5927
|
* Idempotent (`ADD COLUMN IF NOT EXISTS`): re-running on a DB that already has
|
|
5836
|
-
* the column is a no-op.
|
|
5928
|
+
* the column is a no-op. Existing data is preserved (additive nullable column).
|
|
5837
5929
|
*/
|
|
5838
5930
|
async function up$105(db) {
|
|
5839
5931
|
await sql`ALTER TABLE public.join_tokens
|
|
@@ -5859,8 +5951,7 @@ var _047_needs_run_on_exports = /* @__PURE__ */ __exportAll({
|
|
|
5859
5951
|
* - Drop `if_failed`.
|
|
5860
5952
|
*
|
|
5861
5953
|
* Idempotent: column-exists guarded so re-running on a DB that already has
|
|
5862
|
-
* `run_on` (and no `if_failed`) is a no-op. Existing rows are preserved
|
|
5863
|
-
* (staging data is not dropped).
|
|
5954
|
+
* `run_on` (and no `if_failed`) is a no-op. Existing rows are preserved.
|
|
5864
5955
|
*/
|
|
5865
5956
|
const ALL_TERMINAL_JSON = JSON.stringify([
|
|
5866
5957
|
"success",
|
|
@@ -5932,7 +6023,7 @@ var _048_host_reboot_pending_exports = /* @__PURE__ */ __exportAll({
|
|
|
5932
6023
|
* (3) clears on the next reconnect (down-then-up), releasing the held job. NULL
|
|
5933
6024
|
* = no reboot pending.
|
|
5934
6025
|
*
|
|
5935
|
-
* Idempotent (`ADD COLUMN IF NOT EXISTS`); additive, so
|
|
6026
|
+
* Idempotent (`ADD COLUMN IF NOT EXISTS`); additive, so existing data is
|
|
5936
6027
|
* preserved.
|
|
5937
6028
|
*/
|
|
5938
6029
|
async function up$103(db) {
|
|
@@ -5955,7 +6046,7 @@ var _049_held_runs_payload_exports = /* @__PURE__ */ __exportAll({
|
|
|
5955
6046
|
* the dashboard approval queue and the CLI render the computed diff the
|
|
5956
6047
|
* operator approves. NULL for every non-drift hold.
|
|
5957
6048
|
*
|
|
5958
|
-
* Idempotent (`ADD COLUMN IF NOT EXISTS`); additive, so
|
|
6049
|
+
* Idempotent (`ADD COLUMN IF NOT EXISTS`); additive, so existing data is
|
|
5959
6050
|
* preserved.
|
|
5960
6051
|
*/
|
|
5961
6052
|
async function up$102(db) {
|
|
@@ -5981,7 +6072,7 @@ var _050_sources_slug_exports = /* @__PURE__ */ __exportAll({
|
|
|
5981
6072
|
* hasn't populated it yet (manual `--app-id` flow whose initial fetch failed,
|
|
5982
6073
|
* or a row created before the rollout).
|
|
5983
6074
|
*
|
|
5984
|
-
* Idempotent (`ADD COLUMN IF NOT EXISTS`); additive, so
|
|
6075
|
+
* Idempotent (`ADD COLUMN IF NOT EXISTS`); additive, so existing data is
|
|
5985
6076
|
* preserved.
|
|
5986
6077
|
*/
|
|
5987
6078
|
async function up$101(db) {
|
|
@@ -6009,7 +6100,7 @@ var _051_binding_host_pattern_exports = /* @__PURE__ */ __exportAll({
|
|
|
6009
6100
|
* distinct per-host selectors.
|
|
6010
6101
|
*
|
|
6011
6102
|
* Idempotent: re-running on a DB that already has the column / index is a no-op.
|
|
6012
|
-
* Additive —
|
|
6103
|
+
* Additive — existing data is preserved.
|
|
6013
6104
|
*/
|
|
6014
6105
|
async function up$100(db) {
|
|
6015
6106
|
await sql`ALTER TABLE public.environment_bindings
|
|
@@ -6045,7 +6136,7 @@ var _052_host_reach_metadata_exports = /* @__PURE__ */ __exportAll({
|
|
|
6045
6136
|
*
|
|
6046
6137
|
* All nullable: a host with no reach metadata simply cannot be bootstrapped and
|
|
6047
6138
|
* behaves exactly as before. Idempotent (`ADD COLUMN IF NOT EXISTS`); additive,
|
|
6048
|
-
* so
|
|
6139
|
+
* so existing data is preserved.
|
|
6049
6140
|
*/
|
|
6050
6141
|
async function up$99(db) {
|
|
6051
6142
|
await sql`ALTER TABLE public.host_roster
|
|
@@ -6077,7 +6168,7 @@ var _053_agent_token_single_use_exports = /* @__PURE__ */ __exportAll({
|
|
|
6077
6168
|
* default for every existing static / ephemeral token, which stay reusable
|
|
6078
6169
|
* until expiry).
|
|
6079
6170
|
*
|
|
6080
|
-
* Idempotent (`ADD COLUMN IF NOT EXISTS`); additive, so
|
|
6171
|
+
* Idempotent (`ADD COLUMN IF NOT EXISTS`); additive, so existing data is
|
|
6081
6172
|
* preserved.
|
|
6082
6173
|
*/
|
|
6083
6174
|
async function up$98(db) {
|
|
@@ -6102,7 +6193,7 @@ var _054_local_working_tree_exports = /* @__PURE__ */ __exportAll({
|
|
|
6102
6193
|
* lock file). The dashboard renders a "Local machine" badge for these runs
|
|
6103
6194
|
* and avoids building an external repository link from the repo identifier.
|
|
6104
6195
|
*
|
|
6105
|
-
* Idempotent (`ADD COLUMN IF NOT EXISTS`); additive with a default, so
|
|
6196
|
+
* Idempotent (`ADD COLUMN IF NOT EXISTS`); additive with a default, so existing
|
|
6106
6197
|
* data is preserved.
|
|
6107
6198
|
*/
|
|
6108
6199
|
async function up$97(db) {
|
|
@@ -6130,7 +6221,7 @@ var _055_agent_token_mandatory_labels_exports = /* @__PURE__ */ __exportAll({
|
|
|
6130
6221
|
* agent accepts any job its advertised labels match), so every existing token
|
|
6131
6222
|
* stays unconfined until re-minted.
|
|
6132
6223
|
*
|
|
6133
|
-
* Idempotent (`ADD COLUMN IF NOT EXISTS`); additive, so
|
|
6224
|
+
* Idempotent (`ADD COLUMN IF NOT EXISTS`); additive, so existing data is
|
|
6134
6225
|
* preserved.
|
|
6135
6226
|
*/
|
|
6136
6227
|
async function up$96(db) {
|
|
@@ -6157,7 +6248,7 @@ var _056_execution_jobs_environments_exports = /* @__PURE__ */ __exportAll({
|
|
|
6157
6248
|
* fully-resolved list when a deferred-init agent eval resolves dynamic
|
|
6158
6249
|
* elements. NULL = the job binds no environment.
|
|
6159
6250
|
*
|
|
6160
|
-
* Idempotent (`ADD COLUMN IF NOT EXISTS`); additive, so
|
|
6251
|
+
* Idempotent (`ADD COLUMN IF NOT EXISTS`); additive, so existing data is
|
|
6161
6252
|
* preserved.
|
|
6162
6253
|
*/
|
|
6163
6254
|
async function up$95(db) {
|
|
@@ -6468,7 +6559,7 @@ var _064_execution_jobs_skipped_environments_exports = /* @__PURE__ */ __exportA
|
|
|
6468
6559
|
* - `execution_jobs.env_warning text NULL` — the user-visible warning naming the
|
|
6469
6560
|
* skipped environments, surfaced on the dashboard run view. NULL = no warning.
|
|
6470
6561
|
*
|
|
6471
|
-
* Idempotent (`ADD COLUMN IF NOT EXISTS`); additive, so
|
|
6562
|
+
* Idempotent (`ADD COLUMN IF NOT EXISTS`); additive, so existing data is
|
|
6472
6563
|
* preserved.
|
|
6473
6564
|
*/
|
|
6474
6565
|
async function up$87(db) {
|
|
@@ -10851,11 +10942,10 @@ var AccessLogWriter = class {
|
|
|
10851
10942
|
}).execute();
|
|
10852
10943
|
}
|
|
10853
10944
|
/**
|
|
10854
|
-
*
|
|
10945
|
+
* Delegates to `loadAccessLogRange` so cold-store rows are
|
|
10855
10946
|
* merged transparently when pagination crosses the warm cutoff. The
|
|
10856
10947
|
* caller-visible `{items, nextCursor}` shape is preserved. NO
|
|
10857
|
-
* --include-archived flag — access_log is "paginated/transparent"
|
|
10858
|
-
* per design §7 row 11.
|
|
10948
|
+
* --include-archived flag — access_log is "paginated/transparent".
|
|
10859
10949
|
*/
|
|
10860
10950
|
async query(filter) {
|
|
10861
10951
|
const limit = Math.max(1, Math.min(filter.limit ?? 50, 200));
|
|
@@ -11658,7 +11748,7 @@ function uninstallBackupTimer(platform, config, io = defaultTimerIo) {
|
|
|
11658
11748
|
}
|
|
11659
11749
|
//#endregion
|
|
11660
11750
|
//#region src/cli/service/types.ts
|
|
11661
|
-
var DEFAULT_RESTART_POLICY;
|
|
11751
|
+
var DEFAULT_RESTART_POLICY, SERVICE_TEXT;
|
|
11662
11752
|
var init_types$1 = __esmMin((() => {
|
|
11663
11753
|
DEFAULT_RESTART_POLICY = {
|
|
11664
11754
|
enabled: true,
|
|
@@ -11671,6 +11761,16 @@ var init_types$1 = __esmMin((() => {
|
|
|
11671
11761
|
maxRetries: 5,
|
|
11672
11762
|
windowSeconds: 300
|
|
11673
11763
|
};
|
|
11764
|
+
SERVICE_TEXT = {
|
|
11765
|
+
orchestrator: {
|
|
11766
|
+
displayName: "KiCI Orchestrator",
|
|
11767
|
+
description: "KiCI CI/CD workflow orchestrator service"
|
|
11768
|
+
},
|
|
11769
|
+
agent: {
|
|
11770
|
+
displayName: "KiCI Agent",
|
|
11771
|
+
description: "KiCI CI/CD workflow execution agent service"
|
|
11772
|
+
}
|
|
11773
|
+
};
|
|
11674
11774
|
}));
|
|
11675
11775
|
//#endregion
|
|
11676
11776
|
//#region src/cli/service/privilege.ts
|
|
@@ -11747,6 +11847,148 @@ var init_privilege = __esmMin((() => {
|
|
|
11747
11847
|
init_platform_detect();
|
|
11748
11848
|
}));
|
|
11749
11849
|
//#endregion
|
|
11850
|
+
//#region src/cli/service/windows-acl.ts
|
|
11851
|
+
/**
|
|
11852
|
+
* Restricting a Windows service's env file to the service account and to
|
|
11853
|
+
* Administrators.
|
|
11854
|
+
*
|
|
11855
|
+
* Node maps a file mode onto Windows by toggling the read-only attribute and
|
|
11856
|
+
* changes no ACL, so ENV_FILE_MODE protects nothing there. The instance folder
|
|
11857
|
+
* under C:\ProgramData\kici\ inherits read access for BUILTIN\Users from
|
|
11858
|
+
* C:\ProgramData. Its ACL is replaced with full control for LocalSystem (the
|
|
11859
|
+
* account the shawl service runs as) and Administrators, inherited by
|
|
11860
|
+
* everything created in it: the env file, the temporary file a secure write
|
|
11861
|
+
* stages it through, and the service logs are unreadable to other accounts
|
|
11862
|
+
* from the moment they exist.
|
|
11863
|
+
*
|
|
11864
|
+
* Every call passes `/L` and none recurses, so icacls acts on the folder and on
|
|
11865
|
+
* the env file themselves and never on what a link there points at. A
|
|
11866
|
+
* recursive `/reset` or `/setowner` descends into the target of a junction or
|
|
11867
|
+
* of a symbolic link, even with `/L`, and rewrites the ACL of the files there.
|
|
11868
|
+
* The new folder ACL still reaches every entry that inherits from the folder.
|
|
11869
|
+
*/
|
|
11870
|
+
/**
|
|
11871
|
+
* The icacls in the Windows system folder, which also holds the cmd.exe that
|
|
11872
|
+
* COMSPEC names. A bare `icacls` would also be looked up in the current
|
|
11873
|
+
* folder, and the install runs elevated.
|
|
11874
|
+
*/
|
|
11875
|
+
function icaclsPath() {
|
|
11876
|
+
const comspec = process.env.COMSPEC;
|
|
11877
|
+
const systemDir = comspec && /(^|\\)cmd\.exe$/i.test(comspec) ? path.win32.dirname(comspec) : "C:\\Windows\\System32";
|
|
11878
|
+
return path.win32.join(systemDir, "icacls.exe");
|
|
11879
|
+
}
|
|
11880
|
+
/**
|
|
11881
|
+
* The icacls arguments that remove every inherited ACE from `dir` and grant
|
|
11882
|
+
* full control, inherited by files and folders, to LocalSystem and
|
|
11883
|
+
* Administrators. SIDs rather than names, so a localized Windows resolves them.
|
|
11884
|
+
*/
|
|
11885
|
+
function restrictDirArgs(dir) {
|
|
11886
|
+
return [
|
|
11887
|
+
dir,
|
|
11888
|
+
"/inheritance:r",
|
|
11889
|
+
"/grant:r",
|
|
11890
|
+
`*${LOCAL_SYSTEM_SID}:(OI)(CI)F`,
|
|
11891
|
+
`*${ADMINISTRATORS_SID}:(OI)(CI)F`,
|
|
11892
|
+
"/L",
|
|
11893
|
+
"/Q"
|
|
11894
|
+
];
|
|
11895
|
+
}
|
|
11896
|
+
/** The icacls arguments that reset `file` to inherit only from its folder. */
|
|
11897
|
+
function resetFileArgs(file) {
|
|
11898
|
+
return [
|
|
11899
|
+
file,
|
|
11900
|
+
"/reset",
|
|
11901
|
+
"/L",
|
|
11902
|
+
"/Q"
|
|
11903
|
+
];
|
|
11904
|
+
}
|
|
11905
|
+
/**
|
|
11906
|
+
* Restrict the folder that holds `envFilePath`, then reset an existing env
|
|
11907
|
+
* file to inherit only from it, which also drops any explicit ACE on the file.
|
|
11908
|
+
* Run it before the first secret is written into the folder.
|
|
11909
|
+
*/
|
|
11910
|
+
function restrictEnvFileAccess(envFilePath, run = runIcacls, exists = fs.existsSync) {
|
|
11911
|
+
const dir = path.win32.dirname(envFilePath);
|
|
11912
|
+
try {
|
|
11913
|
+
run(restrictDirArgs(dir));
|
|
11914
|
+
if (exists(envFilePath)) run(resetFileArgs(envFilePath));
|
|
11915
|
+
} catch (err) {
|
|
11916
|
+
throw new Error(`could not restrict access to ${dir}: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
|
|
11917
|
+
}
|
|
11918
|
+
}
|
|
11919
|
+
var LOCAL_SYSTEM_SID, ADMINISTRATORS_SID, runIcacls;
|
|
11920
|
+
var init_windows_acl = __esmMin((() => {
|
|
11921
|
+
LOCAL_SYSTEM_SID = "S-1-5-18";
|
|
11922
|
+
ADMINISTRATORS_SID = "S-1-5-32-544";
|
|
11923
|
+
runIcacls = (args) => {
|
|
11924
|
+
execFileSync(icaclsPath(), args, { stdio: "pipe" });
|
|
11925
|
+
};
|
|
11926
|
+
}));
|
|
11927
|
+
//#endregion
|
|
11928
|
+
//#region src/cli/service/windows-env-file.ts
|
|
11929
|
+
/**
|
|
11930
|
+
* Whether a Windows service launch runs a release that reads KICI_ENV_FILE.
|
|
11931
|
+
*
|
|
11932
|
+
* A Windows service is registered with the path of its env file in
|
|
11933
|
+
* KICI_ENV_FILE, never with the values in the file: any local account reads the
|
|
11934
|
+
* command line of a service with `sc.exe qc`. A release from before
|
|
11935
|
+
* KICI_ENV_FILE ignores the path and starts without its configuration, and the
|
|
11936
|
+
* only registration that would run it puts the values back on the command line.
|
|
11937
|
+
* So every command that registers a Windows service, or switches one to another
|
|
11938
|
+
* release, refuses such a release before it changes anything.
|
|
11939
|
+
*/
|
|
11940
|
+
/** Whether `file` holds `marker`, or undefined when it cannot be read. */
|
|
11941
|
+
function fileHolds(file, marker) {
|
|
11942
|
+
let content;
|
|
11943
|
+
try {
|
|
11944
|
+
content = fs.readFileSync(file);
|
|
11945
|
+
} catch {
|
|
11946
|
+
return;
|
|
11947
|
+
}
|
|
11948
|
+
return content.includes(marker);
|
|
11949
|
+
}
|
|
11950
|
+
/**
|
|
11951
|
+
* The file that names a launch's release: the npm entry among its arguments,
|
|
11952
|
+
* or else the executable itself.
|
|
11953
|
+
*/
|
|
11954
|
+
function launchedRelease(launch) {
|
|
11955
|
+
return launch.args?.find((arg) => PACKAGE_ENTRY.test(arg)) ?? launch.executablePath;
|
|
11956
|
+
}
|
|
11957
|
+
/**
|
|
11958
|
+
* Whether the process a Windows service launch starts reads KICI_ENV_FILE.
|
|
11959
|
+
*
|
|
11960
|
+
* A KiCI package runs as `<dir>\<name>.cmd` beside its bundle
|
|
11961
|
+
* `<dir>\lib\<name>.cjs`, which names KICI_ENV_FILE when the release reads it.
|
|
11962
|
+
* An npm install runs node with the `@kici-dev/<component>/dist/<entry>.js`
|
|
11963
|
+
* entry, which then imports the env-file loader. Any other launch, such as a
|
|
11964
|
+
* batch file with no KiCI bundle beside it or a custom executable, is taken to
|
|
11965
|
+
* read it.
|
|
11966
|
+
*/
|
|
11967
|
+
function launchReadsEnvFile(launch) {
|
|
11968
|
+
const { executablePath } = launch;
|
|
11969
|
+
if (/\.(cmd|bat)$/i.test(executablePath)) {
|
|
11970
|
+
const { dir, name } = path.win32.parse(executablePath);
|
|
11971
|
+
const sep = executablePath.includes("\\") ? "\\" : "/";
|
|
11972
|
+
return fileHolds(`${dir}${sep}lib${sep}${name}.cjs`, BUNDLE_MARKER) ?? true;
|
|
11973
|
+
}
|
|
11974
|
+
const entry = launchedRelease(launch);
|
|
11975
|
+
return PACKAGE_ENTRY.test(entry) ? fileHolds(entry, ENTRY_MARKER) ?? true : true;
|
|
11976
|
+
}
|
|
11977
|
+
/**
|
|
11978
|
+
* Why a Windows service may not run `release`, a release from before
|
|
11979
|
+
* KICI_ENV_FILE, and what the operator can do instead.
|
|
11980
|
+
*/
|
|
11981
|
+
function envFileRefusal(release, envFilePath) {
|
|
11982
|
+
return `${release} predates KICI_ENV_FILE, so a Windows service can run it only with the values of ${envFilePath} on its command line, where any local account can read them with sc.exe qc. Use a release that reads KICI_ENV_FILE. To run that release anyway, uninstall the service and install it with the kici-admin of that release.`;
|
|
11983
|
+
}
|
|
11984
|
+
var ENV_FILE_LOADER_EXPORT, BUNDLE_MARKER, ENTRY_MARKER, PACKAGE_ENTRY;
|
|
11985
|
+
var init_windows_env_file = __esmMin((() => {
|
|
11986
|
+
ENV_FILE_LOADER_EXPORT = "./load-service-env-file";
|
|
11987
|
+
BUNDLE_MARKER = SERVICE_ENV_FILE_VAR;
|
|
11988
|
+
ENTRY_MARKER = "@kici-dev/shared/load-service-env-file";
|
|
11989
|
+
PACKAGE_ENTRY = /[/\\]@kici-dev[/\\](?:orchestrator|agent)[/\\]dist[/\\](?:server|standalone)\.js$/;
|
|
11990
|
+
}));
|
|
11991
|
+
//#endregion
|
|
11750
11992
|
//#region src/cli/service/instance/types.ts
|
|
11751
11993
|
/** Narrow an arbitrary string to a Component. */
|
|
11752
11994
|
function isComponent(value) {
|
|
@@ -11757,6 +11999,81 @@ var init_types = __esmMin((() => {
|
|
|
11757
11999
|
COMPONENTS = ["orchestrator", "agent"];
|
|
11758
12000
|
}));
|
|
11759
12001
|
//#endregion
|
|
12002
|
+
//#region src/cli/service/image-digests.ts
|
|
12003
|
+
/**
|
|
12004
|
+
* Resolves the customer installer's image reference from the digest record
|
|
12005
|
+
* shipped with this package (installer-image-digests.json at the package root).
|
|
12006
|
+
*
|
|
12007
|
+
* Emits a manifest-list-digest-pinned ref so a `docker`/`podman pull` verifies
|
|
12008
|
+
* the image hash — a registry that serves substituted bits fails the pull. When
|
|
12009
|
+
* the record is missing (a dev tree that never ran a release) or lacks the
|
|
12010
|
+
* image, falls back to the moving `:latest` tag and warns.
|
|
12011
|
+
*
|
|
12012
|
+
* The record resolves relative to this module's directory and works for both
|
|
12013
|
+
* layouts: the source tree (`src/cli/service/` → the JSON is three dirs up at
|
|
12014
|
+
* the package root) and the single-file bundle (`dist/cli.js` → one dir up).
|
|
12015
|
+
*/
|
|
12016
|
+
/**
|
|
12017
|
+
* Climb from a starting directory looking for installer-image-digests.json.
|
|
12018
|
+
* Works for the source layout (src/cli/service → 3 up) and the single-file
|
|
12019
|
+
* bundle layout (dist → 1 up).
|
|
12020
|
+
*/
|
|
12021
|
+
function findRecordFile(startDir) {
|
|
12022
|
+
let dir = startDir;
|
|
12023
|
+
for (let i = 0; i < 6; i++) {
|
|
12024
|
+
const candidate = path.join(dir, RECORD_FILENAME);
|
|
12025
|
+
if (existsSync(candidate)) return candidate;
|
|
12026
|
+
const parent = path.dirname(dir);
|
|
12027
|
+
if (parent === dir) break;
|
|
12028
|
+
dir = parent;
|
|
12029
|
+
}
|
|
12030
|
+
return null;
|
|
12031
|
+
}
|
|
12032
|
+
/** The published image a component runs, whatever the service is named. */
|
|
12033
|
+
function installerImageName(component) {
|
|
12034
|
+
return `kici-${component}`;
|
|
12035
|
+
}
|
|
12036
|
+
/**
|
|
12037
|
+
* The recorded release and digest of an installer image, or null when the
|
|
12038
|
+
* record is missing, unreadable, or does not hold the image.
|
|
12039
|
+
*/
|
|
12040
|
+
function recordedImage(name, opts) {
|
|
12041
|
+
const file = opts.filePath ?? findRecordFile(import.meta.dirname);
|
|
12042
|
+
if (!file || !existsSync(file)) return null;
|
|
12043
|
+
try {
|
|
12044
|
+
const rec = JSON.parse(readFileSync(file, "utf8"));
|
|
12045
|
+
const digest = rec.images?.[name];
|
|
12046
|
+
return digest && rec.version ? {
|
|
12047
|
+
version: rec.version,
|
|
12048
|
+
digest
|
|
12049
|
+
} : null;
|
|
12050
|
+
} catch {
|
|
12051
|
+
return null;
|
|
12052
|
+
}
|
|
12053
|
+
}
|
|
12054
|
+
/**
|
|
12055
|
+
* The release {@link resolveImageRef} pins `name` at, or null when it falls
|
|
12056
|
+
* back to the moving `:latest` tag.
|
|
12057
|
+
*/
|
|
12058
|
+
function pinnedImageVersion(name, opts = {}) {
|
|
12059
|
+
return recordedImage(name, opts)?.version ?? null;
|
|
12060
|
+
}
|
|
12061
|
+
/**
|
|
12062
|
+
* Resolve `quay.io/kici-dev/<name>:<version>@sha256:<digest>` for an installer
|
|
12063
|
+
* image. Falls back to `:latest` (with a warning) when no recorded digest exists.
|
|
12064
|
+
*/
|
|
12065
|
+
function resolveImageRef(name, opts = {}) {
|
|
12066
|
+
const pinned = recordedImage(name, opts);
|
|
12067
|
+
if (pinned) return `${QUAY_PREFIX}/${name}:${pinned.version}@${pinned.digest}`;
|
|
12068
|
+
console.warn(`[kici] no recorded manifest-list digest for ${name}; pinning the mutable :latest tag. Reinstall from a released kici-admin to get a digest-pinned image.`);
|
|
12069
|
+
return `${QUAY_PREFIX}/${name}:latest`;
|
|
12070
|
+
}
|
|
12071
|
+
var QUAY_PREFIX, RECORD_FILENAME;
|
|
12072
|
+
var init_image_digests = __esmMin((() => {
|
|
12073
|
+
QUAY_PREFIX = "quay.io/kici-dev";
|
|
12074
|
+
RECORD_FILENAME = "installer-image-digests.json";
|
|
12075
|
+
}));
|
|
12076
|
+
//#endregion
|
|
11760
12077
|
//#region src/cli/service/instance/manifest.ts
|
|
11761
12078
|
/**
|
|
11762
12079
|
* Instance manifest — the single source of truth for a folder-anchored
|
|
@@ -11847,6 +12164,90 @@ function resolveVersionFromLaunchSpec(spec, component) {
|
|
|
11847
12164
|
}
|
|
11848
12165
|
return null;
|
|
11849
12166
|
}
|
|
12167
|
+
/**
|
|
12168
|
+
* The version of the launcher a service definition starts, or null when the
|
|
12169
|
+
* launch command does not show one.
|
|
12170
|
+
*
|
|
12171
|
+
* An npm-installed entry script names its version in its package.json
|
|
12172
|
+
* ({@link resolveVersionFromLaunchSpec}). A launcher under the instance's
|
|
12173
|
+
* install base resolves into the `<component>-<version>` folder an archive
|
|
12174
|
+
* upgrade extracted: through the `<component>` symlink on Linux and macOS, or
|
|
12175
|
+
* directly where Windows registers it. A `--binary` anywhere else shows none.
|
|
12176
|
+
*/
|
|
12177
|
+
function resolveLauncherVersion(spec, component, installBase) {
|
|
12178
|
+
const fromPackage = resolveVersionFromLaunchSpec(spec, component);
|
|
12179
|
+
if (fromPackage) return fromPackage;
|
|
12180
|
+
let base;
|
|
12181
|
+
try {
|
|
12182
|
+
base = fs.realpathSync(installBase);
|
|
12183
|
+
} catch {
|
|
12184
|
+
return null;
|
|
12185
|
+
}
|
|
12186
|
+
const prefix = `${component}-`;
|
|
12187
|
+
for (const token of [spec.execPath, ...spec.args]) {
|
|
12188
|
+
if (!path.isAbsolute(token)) continue;
|
|
12189
|
+
let real;
|
|
12190
|
+
try {
|
|
12191
|
+
real = fs.realpathSync(token);
|
|
12192
|
+
} catch {
|
|
12193
|
+
continue;
|
|
12194
|
+
}
|
|
12195
|
+
const [folder = "", ...rest] = path.relative(base, real).split(path.sep);
|
|
12196
|
+
if (rest.length > 0 && folder.startsWith(prefix) && folder.length > prefix.length) return folder.slice(prefix.length);
|
|
12197
|
+
}
|
|
12198
|
+
return null;
|
|
12199
|
+
}
|
|
12200
|
+
/**
|
|
12201
|
+
* Write the manifest `install` built into `instanceDir`.
|
|
12202
|
+
*
|
|
12203
|
+
* A first install writes `fresh` as it is. A re-install of the same instance
|
|
12204
|
+
* (the folder holds a readable manifest of this component and name) updates
|
|
12205
|
+
* that manifest: it keeps what the instance recorded over time — `createdAt`,
|
|
12206
|
+
* the `migrationHeads` the rollback schema guard reads, and any field a newer
|
|
12207
|
+
* CLI wrote — and takes every other field from `fresh`. Its `kiciVersion` is
|
|
12208
|
+
* the version of the launcher `launch` starts; when the launcher shows none,
|
|
12209
|
+
* the recorded version stands, because the CLI that ran `install` need not
|
|
12210
|
+
* be the version the service runs. A compose service runs the image its
|
|
12211
|
+
* compose file pins, whatever `launch` names, so a compose re-install records
|
|
12212
|
+
* the release of that image (`opts.digestRecordPath` overrides the digest
|
|
12213
|
+
* record it reads, for tests).
|
|
12214
|
+
*
|
|
12215
|
+
* A manifest that cannot be read, or that names another instance, is
|
|
12216
|
+
* replaced, and the result carries a warning.
|
|
12217
|
+
*/
|
|
12218
|
+
function writeInstallManifest(instanceDir, fresh, launch, opts = {}) {
|
|
12219
|
+
let existing;
|
|
12220
|
+
try {
|
|
12221
|
+
existing = readManifest(instanceDir, fresh.component);
|
|
12222
|
+
} catch (err) {
|
|
12223
|
+
return {
|
|
12224
|
+
file: writeManifest(instanceDir, fresh),
|
|
12225
|
+
manifest: fresh,
|
|
12226
|
+
warning: `${err.message}. install replaced it, so the migration heads it recorded, if any, are lost.`
|
|
12227
|
+
};
|
|
12228
|
+
}
|
|
12229
|
+
if (!existing) return {
|
|
12230
|
+
file: writeManifest(instanceDir, fresh),
|
|
12231
|
+
manifest: fresh
|
|
12232
|
+
};
|
|
12233
|
+
if (existing.name !== fresh.name) return {
|
|
12234
|
+
file: writeManifest(instanceDir, fresh),
|
|
12235
|
+
manifest: fresh,
|
|
12236
|
+
warning: `${manifestPath(instanceDir, fresh.component)} described the instance "${existing.name}". It now describes "${fresh.name}".`
|
|
12237
|
+
};
|
|
12238
|
+
const launcherVersion = fresh.platform === "compose" ? pinnedImageVersion(installerImageName(fresh.component), { filePath: opts.digestRecordPath }) : resolveLauncherVersion(launch, fresh.component, fresh.installBase);
|
|
12239
|
+
const manifest = {
|
|
12240
|
+
...existing,
|
|
12241
|
+
...fresh,
|
|
12242
|
+
createdAt: existing.createdAt,
|
|
12243
|
+
kiciVersion: launcherVersion ?? existing.kiciVersion
|
|
12244
|
+
};
|
|
12245
|
+
if (existing.migrationHeads) manifest.migrationHeads = existing.migrationHeads;
|
|
12246
|
+
return {
|
|
12247
|
+
file: writeManifest(instanceDir, manifest),
|
|
12248
|
+
manifest
|
|
12249
|
+
};
|
|
12250
|
+
}
|
|
11850
12251
|
function resolveNpmInstallTarget(spec, component, opts) {
|
|
11851
12252
|
const entryRe = new RegExp(`[/\\\\]@kici-dev[/\\\\]${component}[/\\\\]dist[/\\\\](?:server|standalone)\\.js$`);
|
|
11852
12253
|
const entry = [spec.execPath, ...spec.args].find((a) => entryRe.test(a));
|
|
@@ -11882,6 +12283,7 @@ function firstGlobalPackage(entry) {
|
|
|
11882
12283
|
}
|
|
11883
12284
|
var REQUIRED_FIELDS;
|
|
11884
12285
|
var init_manifest = __esmMin((() => {
|
|
12286
|
+
init_image_digests();
|
|
11885
12287
|
REQUIRED_FIELDS = [
|
|
11886
12288
|
"component",
|
|
11887
12289
|
"name",
|
|
@@ -12449,6 +12851,43 @@ var init_systemd = __esmMin((() => {
|
|
|
12449
12851
|
};
|
|
12450
12852
|
}));
|
|
12451
12853
|
//#endregion
|
|
12854
|
+
//#region src/cli/service/shutdown-grace.ts
|
|
12855
|
+
/**
|
|
12856
|
+
* Seconds an init system must wait after SIGTERM before it SIGKILLs the service.
|
|
12857
|
+
*
|
|
12858
|
+
* The runtime defaults are shorter than either component's own graceful-shutdown
|
|
12859
|
+
* budget: Compose waits 10s, launchd's default ExitTimeOut is 5s, and shawl's
|
|
12860
|
+
* default `--stop-timeout` on Windows is 3s. A teardown that
|
|
12861
|
+
* takes longer is killed partway through: the orchestrator never broadcasts
|
|
12862
|
+
* `peer.leaving` and never closes its agent sockets, and the agent never
|
|
12863
|
+
* finishes draining. That window is reached exactly when it matters most — the
|
|
12864
|
+
* orchestrator's scaler stops each managed agent with its own grace, so an
|
|
12865
|
+
* orchestrator with agents in flight routinely needs more than 10s.
|
|
12866
|
+
*
|
|
12867
|
+
* Each value is its component's force-exit budget plus headroom, so the
|
|
12868
|
+
* process always reaches its own timer first and the runtime's kill stays the
|
|
12869
|
+
* last resort it is meant to be:
|
|
12870
|
+
* - orchestrator: 30s (`setupGracefulShutdown` default) plus up to 2s of log
|
|
12871
|
+
* file flush -> 45s
|
|
12872
|
+
* - agent: 10s plus a ~1s abort delay in its `onForceExit` -> 20s
|
|
12873
|
+
*
|
|
12874
|
+
* An unrecognised component gets the larger value: over-waiting costs a few
|
|
12875
|
+
* seconds on shutdown, under-waiting corrupts it.
|
|
12876
|
+
*/
|
|
12877
|
+
function shutdownGraceSeconds(config) {
|
|
12878
|
+
return (config.component ?? (config.name === "kici-agent" ? "agent" : "orchestrator")) === "agent" ? 20 : 45;
|
|
12879
|
+
}
|
|
12880
|
+
/**
|
|
12881
|
+
* Seconds a `stop` waits for the init system to report the service stopped.
|
|
12882
|
+
*
|
|
12883
|
+
* The init system kills the process once its grace elapses, so a service still
|
|
12884
|
+
* running after the grace plus this margin is hung, not slow.
|
|
12885
|
+
*/
|
|
12886
|
+
function stopWaitSeconds(config) {
|
|
12887
|
+
return shutdownGraceSeconds(config) + 15;
|
|
12888
|
+
}
|
|
12889
|
+
var init_shutdown_grace = __esmMin((() => {}));
|
|
12890
|
+
//#endregion
|
|
12452
12891
|
//#region src/cli/service/launchd.ts
|
|
12453
12892
|
/**
|
|
12454
12893
|
* launchd service manager implementation.
|
|
@@ -12471,14 +12910,19 @@ function stripLabelPrefix(label) {
|
|
|
12471
12910
|
return label;
|
|
12472
12911
|
}
|
|
12473
12912
|
/** Async sleep used to pace launchd bootout/bootstrap reconciliation. */
|
|
12474
|
-
function sleep(ms) {
|
|
12913
|
+
function sleep$1(ms) {
|
|
12475
12914
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
12476
12915
|
}
|
|
12477
|
-
var LABEL_PREFIX, KNOWN_LABEL_PREFIXES, SYSTEM_LOG_DIR, LaunchdServiceManager;
|
|
12916
|
+
var LABEL_PREFIX, KNOWN_LABEL_PREFIXES, SYSTEM_LOG_DIR, KICI_COMPONENT_MARKER, PRINT_NO_SUCH_SERVICE, PRINT_NO_SUCH_DOMAIN, FAILED_BOOTOUT_SETTLE_MS, LaunchdServiceManager;
|
|
12478
12917
|
var init_launchd = __esmMin((() => {
|
|
12918
|
+
init_shutdown_grace();
|
|
12479
12919
|
LABEL_PREFIX = "dev.kici";
|
|
12480
12920
|
KNOWN_LABEL_PREFIXES = ["dev.kici.", "com.kici."];
|
|
12481
12921
|
SYSTEM_LOG_DIR = "/var/log/kici";
|
|
12922
|
+
KICI_COMPONENT_MARKER = /<key>KiCIComponent<\/key>\s*<string>(orchestrator|agent)<\/string>/;
|
|
12923
|
+
PRINT_NO_SUCH_SERVICE = 113;
|
|
12924
|
+
PRINT_NO_SUCH_DOMAIN = 112;
|
|
12925
|
+
FAILED_BOOTOUT_SETTLE_MS = 5e3;
|
|
12482
12926
|
LaunchdServiceManager = class {
|
|
12483
12927
|
platform = "launchd";
|
|
12484
12928
|
/** Build the launchd label for a service. */
|
|
@@ -12490,11 +12934,51 @@ var init_launchd = __esmMin((() => {
|
|
|
12490
12934
|
if (config.isUserLevel) return path.join(os.homedir(), "Library", "Logs", "kici");
|
|
12491
12935
|
return SYSTEM_LOG_DIR;
|
|
12492
12936
|
}
|
|
12493
|
-
/**
|
|
12494
|
-
|
|
12495
|
-
|
|
12496
|
-
|
|
12497
|
-
|
|
12937
|
+
/** The directory launchd loads this service level's plists from. */
|
|
12938
|
+
plistDir(isUserLevel) {
|
|
12939
|
+
return isUserLevel ? path.join(os.homedir(), "Library", "LaunchAgents") : path.join("/Library", "LaunchDaemons");
|
|
12940
|
+
}
|
|
12941
|
+
/** The job `install` writes: `dev.kici.<name>`. */
|
|
12942
|
+
installJob(config) {
|
|
12943
|
+
const label = this.label(config);
|
|
12944
|
+
return {
|
|
12945
|
+
label,
|
|
12946
|
+
plistPath: path.join(this.plistDir(config.isUserLevel), `${label}.plist`)
|
|
12947
|
+
};
|
|
12948
|
+
}
|
|
12949
|
+
/**
|
|
12950
|
+
* An installed job for this name under another label: a `com.kici.<name>`
|
|
12951
|
+
* plist, or a plist named after the service itself — each one `list()`
|
|
12952
|
+
* reports as this instance. Only a plist that carries the KiCI component
|
|
12953
|
+
* marker counts, and the job is addressed by the Label the plist declares.
|
|
12954
|
+
*/
|
|
12955
|
+
otherLabelJob(config) {
|
|
12956
|
+
const prefixes = [...KNOWN_LABEL_PREFIXES.filter((p) => p !== `${LABEL_PREFIX}.`), ""];
|
|
12957
|
+
for (const prefix of prefixes) {
|
|
12958
|
+
const stem = `${prefix}${config.name}`;
|
|
12959
|
+
if (stripLabelPrefix(stem) !== config.name) continue;
|
|
12960
|
+
const plistPath = path.join(this.plistDir(config.isUserLevel), `${stem}.plist`);
|
|
12961
|
+
if (!fs.existsSync(plistPath)) continue;
|
|
12962
|
+
const content = fs.readFileSync(plistPath, "utf-8");
|
|
12963
|
+
if (!KICI_COMPONENT_MARKER.test(content)) continue;
|
|
12964
|
+
const label = content.match(/<key>Label<\/key>\s*<string>([^<]+)<\/string>/);
|
|
12965
|
+
return {
|
|
12966
|
+
label: label ? this.unescapeXml(label[1]) : stem,
|
|
12967
|
+
plistPath
|
|
12968
|
+
};
|
|
12969
|
+
}
|
|
12970
|
+
return null;
|
|
12971
|
+
}
|
|
12972
|
+
/**
|
|
12973
|
+
* The job a lifecycle command acts on: the `dev.kici.<name>` plist when it
|
|
12974
|
+
* is installed, otherwise a job this name was installed under with another
|
|
12975
|
+
* label, so every instance `list()` reports can be stopped, started and
|
|
12976
|
+
* removed. With neither installed, the `dev.kici.<name>` job.
|
|
12977
|
+
*/
|
|
12978
|
+
resolveJob(config) {
|
|
12979
|
+
const job = this.installJob(config);
|
|
12980
|
+
if (fs.existsSync(job.plistPath)) return job;
|
|
12981
|
+
return this.otherLabelJob(config) ?? job;
|
|
12498
12982
|
}
|
|
12499
12983
|
/** Parse a .env file into key-value pairs. */
|
|
12500
12984
|
parseEnvFile(envFilePath) {
|
|
@@ -12550,11 +13034,18 @@ var init_launchd = __esmMin((() => {
|
|
|
12550
13034
|
lines.push(` <string>${this.escapeXml(config.workingDirectory)}</string>`);
|
|
12551
13035
|
lines.push(" <key>RunAtLoad</key>");
|
|
12552
13036
|
lines.push(" <true/>");
|
|
12553
|
-
lines.push(" <key>
|
|
12554
|
-
lines.push(
|
|
12555
|
-
if (config.restartPolicy.enabled
|
|
12556
|
-
lines.push(" <key>
|
|
12557
|
-
lines.push(
|
|
13037
|
+
lines.push(" <key>ExitTimeOut</key>");
|
|
13038
|
+
lines.push(` <integer>${shutdownGraceSeconds(config)}</integer>`);
|
|
13039
|
+
if (config.restartPolicy.enabled) {
|
|
13040
|
+
lines.push(" <key>KeepAlive</key>");
|
|
13041
|
+
lines.push(" <dict>");
|
|
13042
|
+
lines.push(" <key>SuccessfulExit</key>");
|
|
13043
|
+
lines.push(" <false/>");
|
|
13044
|
+
lines.push(" </dict>");
|
|
13045
|
+
if (config.restartPolicy.delays.length > 0) {
|
|
13046
|
+
lines.push(" <key>ThrottleInterval</key>");
|
|
13047
|
+
lines.push(` <integer>${config.restartPolicy.delays[0]}</integer>`);
|
|
13048
|
+
}
|
|
12558
13049
|
}
|
|
12559
13050
|
lines.push(" <key>StandardOutPath</key>");
|
|
12560
13051
|
lines.push(` <string>${this.escapeXml(path.join(logDirectory, `${config.name}.out.log`))}</string>`);
|
|
@@ -12590,30 +13081,48 @@ var init_launchd = __esmMin((() => {
|
|
|
12590
13081
|
* domain from the plist's filesystem location, but they fail silently on
|
|
12591
13082
|
* headless macOS hosts where no GUI session exists (the `gui/<uid>` domain
|
|
12592
13083
|
* is not available without a console login). The modern `bootstrap` /
|
|
12593
|
-
* `bootout` / `kickstart` / `
|
|
13084
|
+
* `bootout` / `kickstart` / `print` verbs take the domain explicitly, so a
|
|
12594
13085
|
* system-level LaunchDaemon loads regardless of whether anyone is logged
|
|
12595
13086
|
* in at the console — exactly what we need for headless deploy targets.
|
|
12596
13087
|
*/
|
|
12597
13088
|
domain(config) {
|
|
12598
13089
|
return config.isUserLevel ? `gui/${os.userInfo().uid}` : "system";
|
|
12599
13090
|
}
|
|
12600
|
-
/** `<domain>/<label>` — the target string for kickstart/
|
|
12601
|
-
domainTarget(config) {
|
|
12602
|
-
return `${this.domain(config)}/${
|
|
13091
|
+
/** `<domain>/<label>` — the target string for bootout/kickstart/print. */
|
|
13092
|
+
domainTarget(config, job) {
|
|
13093
|
+
return `${this.domain(config)}/${job.label}`;
|
|
12603
13094
|
}
|
|
12604
|
-
/**
|
|
12605
|
-
|
|
13095
|
+
/**
|
|
13096
|
+
* `launchctl print <domain>/<label>`: the job's description while it is
|
|
13097
|
+
* loaded, or null when launchd reports it not loaded. Unlike `launchctl
|
|
13098
|
+
* list`, which lists only the caller's own domain, this reads a system
|
|
13099
|
+
* daemon without root. Any other failure throws, so a job that could not be
|
|
13100
|
+
* inspected is never mistaken for a stopped one.
|
|
13101
|
+
*/
|
|
13102
|
+
printJob(config, job) {
|
|
12606
13103
|
try {
|
|
12607
|
-
execFileSync("launchctl", ["print", this.domainTarget(config)], {
|
|
12608
|
-
|
|
12609
|
-
|
|
12610
|
-
|
|
13104
|
+
return execFileSync("launchctl", ["print", this.domainTarget(config, job)], {
|
|
13105
|
+
encoding: "utf-8",
|
|
13106
|
+
stdio: [
|
|
13107
|
+
"ignore",
|
|
13108
|
+
"pipe",
|
|
13109
|
+
"pipe"
|
|
13110
|
+
]
|
|
13111
|
+
});
|
|
13112
|
+
} catch (err) {
|
|
13113
|
+
const status = err.status;
|
|
13114
|
+
if (status === PRINT_NO_SUCH_SERVICE || status === PRINT_NO_SUCH_DOMAIN) return null;
|
|
13115
|
+
throw err;
|
|
12611
13116
|
}
|
|
12612
13117
|
}
|
|
13118
|
+
/** Is the job currently loaded in its target domain? */
|
|
13119
|
+
isLoaded(config, job) {
|
|
13120
|
+
return this.printJob(config, job) !== null;
|
|
13121
|
+
}
|
|
12613
13122
|
async install(config) {
|
|
12614
|
-
const
|
|
13123
|
+
const job = this.installJob(config);
|
|
12615
13124
|
const plistContent = this.generatePlist(config);
|
|
12616
|
-
fs.mkdirSync(path.dirname(
|
|
13125
|
+
fs.mkdirSync(path.dirname(job.plistPath), { recursive: true });
|
|
12617
13126
|
const logDirectory = this.logDir(config);
|
|
12618
13127
|
fs.mkdirSync(logDirectory, { recursive: true });
|
|
12619
13128
|
if (!config.isUserLevel && config.user) execFileSync("chown", [
|
|
@@ -12621,44 +13130,51 @@ var init_launchd = __esmMin((() => {
|
|
|
12621
13130
|
`${config.user}:staff`,
|
|
12622
13131
|
logDirectory
|
|
12623
13132
|
], { stdio: "inherit" });
|
|
12624
|
-
|
|
12625
|
-
if (
|
|
12626
|
-
|
|
12627
|
-
|
|
12628
|
-
} catch {}
|
|
12629
|
-
await this.waitUntilUnloaded(config);
|
|
13133
|
+
const otherJob = this.otherLabelJob(config);
|
|
13134
|
+
if (otherJob) {
|
|
13135
|
+
await this.unload(config, otherJob);
|
|
13136
|
+
fs.unlinkSync(otherJob.plistPath);
|
|
12630
13137
|
}
|
|
12631
|
-
|
|
13138
|
+
fs.writeFileSync(job.plistPath, plistContent, "utf-8");
|
|
13139
|
+
await this.unload(config, job);
|
|
13140
|
+
await this.bootstrapWithRetry(config, job, { replaceLoaded: true });
|
|
12632
13141
|
}
|
|
12633
13142
|
/**
|
|
12634
|
-
* Poll until the
|
|
12635
|
-
*
|
|
12636
|
-
*
|
|
13143
|
+
* Poll until the job is no longer loaded in its target domain, or the
|
|
13144
|
+
* deadline elapses. Resolves true once the job has left the domain,
|
|
13145
|
+
* false on timeout. `launchctl bootout` is asynchronous — it returns before
|
|
13146
|
+
* launchd has finished releasing the job — so a bootstrap issued
|
|
12637
13147
|
* immediately afterward races the teardown and fails with EIO. Waiting for
|
|
12638
13148
|
* the unload to complete closes that race for the common case; the residual
|
|
12639
13149
|
* window is covered by bootstrapWithRetry.
|
|
12640
13150
|
*/
|
|
12641
|
-
async waitUntilUnloaded(config) {
|
|
12642
|
-
const deadline = Date.now() +
|
|
12643
|
-
while (this.isLoaded(config)) {
|
|
12644
|
-
if (Date.now() >= deadline) return;
|
|
12645
|
-
await sleep(500);
|
|
13151
|
+
async waitUntilUnloaded(config, job, timeoutMs) {
|
|
13152
|
+
const deadline = Date.now() + timeoutMs;
|
|
13153
|
+
while (this.isLoaded(config, job)) {
|
|
13154
|
+
if (Date.now() >= deadline) return false;
|
|
13155
|
+
await sleep$1(500);
|
|
12646
13156
|
}
|
|
13157
|
+
return true;
|
|
12647
13158
|
}
|
|
12648
13159
|
/**
|
|
12649
13160
|
* Bootstrap into the target domain, retrying on the transient EIO
|
|
12650
13161
|
* ("5: Input/output error") launchd returns when a just-removed service has
|
|
12651
13162
|
* not finished tearing down. A genuine, non-transient failure (bad plist,
|
|
12652
|
-
* permission denied) is re-thrown on the first attempt.
|
|
12653
|
-
*
|
|
13163
|
+
* permission denied) is re-thrown on the first attempt.
|
|
13164
|
+
*
|
|
13165
|
+
* launchd answers a bootstrap of a job that is already loaded with the same
|
|
13166
|
+
* EIO. With `replaceLoaded` (install, which just wrote a new plist) such a
|
|
13167
|
+
* job is booted out before the next try. Without it (start), a job that
|
|
13168
|
+
* another caller loaded meanwhile is the outcome start wanted: RunAtLoad has
|
|
13169
|
+
* started it, so the call succeeds and leaves it running.
|
|
12654
13170
|
*/
|
|
12655
|
-
async bootstrapWithRetry(config,
|
|
13171
|
+
async bootstrapWithRetry(config, job, opts) {
|
|
12656
13172
|
const attempts = 5;
|
|
12657
13173
|
for (let attempt = 1; attempt <= attempts; attempt++) try {
|
|
12658
13174
|
execFileSync("launchctl", [
|
|
12659
13175
|
"bootstrap",
|
|
12660
13176
|
this.domain(config),
|
|
12661
|
-
|
|
13177
|
+
job.plistPath
|
|
12662
13178
|
], { stdio: [
|
|
12663
13179
|
"inherit",
|
|
12664
13180
|
"inherit",
|
|
@@ -12666,66 +13182,92 @@ var init_launchd = __esmMin((() => {
|
|
|
12666
13182
|
] });
|
|
12667
13183
|
return;
|
|
12668
13184
|
} catch (err) {
|
|
13185
|
+
if (!opts.replaceLoaded && this.isLoaded(config, job)) return;
|
|
12669
13186
|
const e = err;
|
|
12670
13187
|
const stderr = (e.stderr ?? "").toString();
|
|
12671
13188
|
if (!(e.status === 5 || /input\/output error|resource busy/i.test(stderr)) || attempt === attempts) {
|
|
12672
13189
|
if (stderr) process.stderr.write(stderr);
|
|
12673
13190
|
throw err;
|
|
12674
13191
|
}
|
|
12675
|
-
if (this.isLoaded(config)) try {
|
|
12676
|
-
execFileSync("launchctl", ["bootout", this.domainTarget(config)], { stdio: "inherit" });
|
|
13192
|
+
if (opts.replaceLoaded && this.isLoaded(config, job)) try {
|
|
13193
|
+
execFileSync("launchctl", ["bootout", this.domainTarget(config, job)], { stdio: "inherit" });
|
|
12677
13194
|
} catch {}
|
|
12678
|
-
await sleep(1e3 * attempt);
|
|
13195
|
+
await sleep$1(1e3 * attempt);
|
|
12679
13196
|
}
|
|
12680
13197
|
}
|
|
12681
13198
|
async uninstall(config) {
|
|
12682
|
-
const
|
|
13199
|
+
const job = this.resolveJob(config);
|
|
12683
13200
|
try {
|
|
12684
|
-
execFileSync("launchctl", ["bootout", this.domainTarget(config)], { stdio:
|
|
13201
|
+
execFileSync("launchctl", ["bootout", this.domainTarget(config, job)], { stdio: [
|
|
13202
|
+
"inherit",
|
|
13203
|
+
"inherit",
|
|
13204
|
+
"pipe"
|
|
13205
|
+
] });
|
|
12685
13206
|
} catch {}
|
|
12686
13207
|
try {
|
|
12687
|
-
fs.unlinkSync(
|
|
13208
|
+
fs.unlinkSync(job.plistPath);
|
|
12688
13209
|
} catch {}
|
|
12689
13210
|
}
|
|
12690
13211
|
async start(config) {
|
|
12691
|
-
|
|
12692
|
-
|
|
12693
|
-
|
|
12694
|
-
|
|
12695
|
-
|
|
13212
|
+
const job = this.resolveJob(config);
|
|
13213
|
+
if (!this.isLoaded(config, job)) {
|
|
13214
|
+
await this.bootstrapWithRetry(config, job, { replaceLoaded: false });
|
|
13215
|
+
return;
|
|
13216
|
+
}
|
|
13217
|
+
execFileSync("launchctl", ["kickstart", this.domainTarget(config, job)], { stdio: "inherit" });
|
|
12696
13218
|
}
|
|
12697
13219
|
async stop(config) {
|
|
12698
|
-
|
|
12699
|
-
|
|
12700
|
-
|
|
12701
|
-
|
|
12702
|
-
|
|
13220
|
+
const job = this.resolveJob(config);
|
|
13221
|
+
if (!this.isLoaded(config, job) && !fs.existsSync(job.plistPath)) throw new Error(`launchd job ${job.label} is not installed: no job is loaded and ${job.plistPath} does not exist.`);
|
|
13222
|
+
await this.unload(config, job);
|
|
13223
|
+
}
|
|
13224
|
+
/**
|
|
13225
|
+
* Boot the job out of its domain and wait until launchd has finished the
|
|
13226
|
+
* teardown. Does nothing when no job is loaded.
|
|
13227
|
+
*/
|
|
13228
|
+
async unload(config, job) {
|
|
13229
|
+
if (!this.isLoaded(config, job)) return;
|
|
13230
|
+
let bootoutError;
|
|
13231
|
+
try {
|
|
13232
|
+
execFileSync("launchctl", ["bootout", this.domainTarget(config, job)], { stdio: [
|
|
13233
|
+
"inherit",
|
|
13234
|
+
"inherit",
|
|
13235
|
+
"pipe"
|
|
13236
|
+
] });
|
|
13237
|
+
} catch (err) {
|
|
13238
|
+
bootoutError = err;
|
|
13239
|
+
}
|
|
13240
|
+
if (bootoutError !== void 0) {
|
|
13241
|
+
if (await this.waitUntilUnloaded(config, job, FAILED_BOOTOUT_SETTLE_MS)) return;
|
|
13242
|
+
const stderr = String(bootoutError.stderr ?? "");
|
|
13243
|
+
if (stderr) process.stderr.write(stderr);
|
|
13244
|
+
throw bootoutError;
|
|
13245
|
+
}
|
|
13246
|
+
const timeoutSeconds = stopWaitSeconds(config);
|
|
13247
|
+
if (!await this.waitUntilUnloaded(config, job, timeoutSeconds * 1e3)) throw new Error(`launchd job ${job.label} is still loaded ${timeoutSeconds}s after launchctl bootout. Its process may be hung — inspect it with \`launchctl print ${this.domainTarget(config, job)}\`.`);
|
|
12703
13248
|
}
|
|
12704
13249
|
async restart(config) {
|
|
12705
13250
|
await this.stop(config);
|
|
12706
13251
|
await this.start(config);
|
|
12707
13252
|
}
|
|
12708
13253
|
async status(config) {
|
|
12709
|
-
const
|
|
13254
|
+
const job = this.resolveJob(config);
|
|
13255
|
+
let description;
|
|
12710
13256
|
try {
|
|
12711
|
-
|
|
12712
|
-
|
|
12713
|
-
|
|
12714
|
-
|
|
12715
|
-
|
|
12716
|
-
|
|
12717
|
-
|
|
12718
|
-
|
|
12719
|
-
|
|
12720
|
-
|
|
12721
|
-
|
|
12722
|
-
|
|
12723
|
-
|
|
12724
|
-
|
|
12725
|
-
}
|
|
12726
|
-
}
|
|
12727
|
-
} catch {}
|
|
12728
|
-
return { state: "unknown" };
|
|
13257
|
+
description = this.printJob(config, job);
|
|
13258
|
+
} catch {
|
|
13259
|
+
return { state: "unknown" };
|
|
13260
|
+
}
|
|
13261
|
+
if (description === null) return fs.existsSync(job.plistPath) ? { state: "stopped" } : { state: "unknown" };
|
|
13262
|
+
const pid = description.match(/^\tpid = (\d+)$/m);
|
|
13263
|
+
if (pid) return {
|
|
13264
|
+
state: "running",
|
|
13265
|
+
pid: parseInt(pid[1], 10)
|
|
13266
|
+
};
|
|
13267
|
+
const lastExit = description.match(/^\tlast exit code = (-?\d+)/m);
|
|
13268
|
+
if (lastExit && parseInt(lastExit[1], 10) !== 0) return { state: "failed" };
|
|
13269
|
+
if (/^\tlast terminating signal = /m.test(description)) return { state: "failed" };
|
|
13270
|
+
return { state: "stopped" };
|
|
12729
13271
|
}
|
|
12730
13272
|
async logs(config, options) {
|
|
12731
13273
|
const logDirectory = this.logDir(config);
|
|
@@ -12743,10 +13285,10 @@ var init_launchd = __esmMin((() => {
|
|
|
12743
13285
|
});
|
|
12744
13286
|
}
|
|
12745
13287
|
async isInstalled(config) {
|
|
12746
|
-
return fs.existsSync(this.
|
|
13288
|
+
return fs.existsSync(this.resolveJob(config).plistPath);
|
|
12747
13289
|
}
|
|
12748
13290
|
async list(isUserLevel) {
|
|
12749
|
-
const baseDir =
|
|
13291
|
+
const baseDir = this.plistDir(isUserLevel);
|
|
12750
13292
|
if (!fs.existsSync(baseDir)) return [];
|
|
12751
13293
|
const out = [];
|
|
12752
13294
|
for (const entry of fs.readdirSync(baseDir)) {
|
|
@@ -12759,7 +13301,7 @@ var init_launchd = __esmMin((() => {
|
|
|
12759
13301
|
if (err?.code === "ENOENT") continue;
|
|
12760
13302
|
throw new Error(`could not read the launchd job at ${path.join(baseDir, entry)}: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
|
|
12761
13303
|
}
|
|
12762
|
-
const match = content.match(
|
|
13304
|
+
const match = content.match(KICI_COMPONENT_MARKER);
|
|
12763
13305
|
if (!match) continue;
|
|
12764
13306
|
const dirMatch = content.match(/<key>KiCIInstanceDir<\/key>\s*<string>([^<]+)<\/string>/);
|
|
12765
13307
|
out.push({
|
|
@@ -12775,7 +13317,7 @@ var init_launchd = __esmMin((() => {
|
|
|
12775
13317
|
async readLaunchSpec(config) {
|
|
12776
13318
|
let content;
|
|
12777
13319
|
try {
|
|
12778
|
-
content = fs.readFileSync(this.
|
|
13320
|
+
content = fs.readFileSync(this.resolveJob(config).plistPath, "utf-8");
|
|
12779
13321
|
} catch {
|
|
12780
13322
|
return null;
|
|
12781
13323
|
}
|
|
@@ -13044,12 +13586,75 @@ function parseStateCode(output) {
|
|
|
13044
13586
|
const match = output.match(/STATE\s*:\s*(\d+)/);
|
|
13045
13587
|
return match ? parseInt(match[1], 10) : void 0;
|
|
13046
13588
|
}
|
|
13047
|
-
|
|
13589
|
+
/** Async sleep used to pace the `sc.exe query` polls. */
|
|
13590
|
+
function sleep(ms) {
|
|
13591
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
13592
|
+
}
|
|
13593
|
+
/** A `.cmd` or `.bat` file, which Windows runs through cmd.exe. */
|
|
13594
|
+
function isBatchFile(executablePath) {
|
|
13595
|
+
return /\.(cmd|bat)$/i.test(executablePath);
|
|
13596
|
+
}
|
|
13597
|
+
/**
|
|
13598
|
+
* The cmd.exe that COMSPEC names, as Node itself uses to run a shell on Windows.
|
|
13599
|
+
* A COMSPEC that names another shell is not used: the wrapper arguments are
|
|
13600
|
+
* cmd.exe's.
|
|
13601
|
+
*/
|
|
13602
|
+
function cmdExePath() {
|
|
13603
|
+
const comspec = process.env.COMSPEC;
|
|
13604
|
+
return comspec && /(^|\\)cmd\.exe$/i.test(comspec) ? comspec : "C:\\Windows\\System32\\cmd.exe";
|
|
13605
|
+
}
|
|
13606
|
+
/**
|
|
13607
|
+
* Throw when cmd.exe would read part of `token` as syntax.
|
|
13608
|
+
*
|
|
13609
|
+
* shawl quotes a token on cmd.exe's command line only when it contains a space
|
|
13610
|
+
* or a tab. Inside quotes cmd.exe reads `&`, `|`, `<`, `>`, `(`, `)` and the
|
|
13611
|
+
* `,` `;` `=` delimiters as text; outside quotes it reads them as syntax. `%`
|
|
13612
|
+
* and `^` change even inside quotes (`call` expands `%` again and doubles `^`),
|
|
13613
|
+
* and a `"` ends the quoting.
|
|
13614
|
+
*/
|
|
13615
|
+
function assertCmdSafe(token) {
|
|
13616
|
+
const found = (/[ \t]/.test(token) ? /["%^]/ : /["%^&|<>(),;=]/).exec(token);
|
|
13617
|
+
if (found) throw new Error(`cannot run "${token}" as a Windows service: it contains "${found[0]}", which cmd.exe reads as syntax when it runs the batch file. Install from a path without it.`);
|
|
13618
|
+
}
|
|
13619
|
+
/**
|
|
13620
|
+
* The command shawl registers for the service: the executable and its
|
|
13621
|
+
* arguments, with a batch file run through cmd.exe ({@link BATCH_WRAPPER_ARGS}).
|
|
13622
|
+
*/
|
|
13623
|
+
function serviceCommand(config) {
|
|
13624
|
+
const command = [config.executablePath, ...config.args ?? []];
|
|
13625
|
+
if (!isBatchFile(config.executablePath)) return command;
|
|
13626
|
+
command.forEach(assertCmdSafe);
|
|
13627
|
+
return [
|
|
13628
|
+
cmdExePath(),
|
|
13629
|
+
...BATCH_WRAPPER_ARGS,
|
|
13630
|
+
...command,
|
|
13631
|
+
BATCH_STDIN
|
|
13632
|
+
];
|
|
13633
|
+
}
|
|
13634
|
+
/** The launch command inside a {@link serviceCommand}: a batch file's cmd.exe wrapper removed. */
|
|
13635
|
+
function unwrapServiceCommand(tokens) {
|
|
13636
|
+
const [exe, ...rest] = tokens;
|
|
13637
|
+
return exe !== void 0 && /(^|\\)cmd\.exe$/i.test(exe) && BATCH_WRAPPER_ARGS.every((arg, i) => rest[i]?.toLowerCase() === arg) && rest.at(-1) === BATCH_STDIN && rest.length > BATCH_WRAPPER_ARGS.length + 1 ? rest.slice(BATCH_WRAPPER_ARGS.length, -1) : tokens;
|
|
13638
|
+
}
|
|
13639
|
+
var SERVICE_REGISTRY_PROBE_TIMEOUT_MS, ERROR_SERVICE_NOT_ACTIVE, ERROR_SERVICE_CANNOT_ACCEPT_CTRL, BATCH_WRAPPER_ARGS, BATCH_STDIN, WindowsServiceManager;
|
|
13048
13640
|
var init_windows = __esmMin((() => {
|
|
13049
13641
|
init_downloader();
|
|
13050
13642
|
init_registry();
|
|
13051
13643
|
init_platform_detect();
|
|
13644
|
+
init_shutdown_grace();
|
|
13645
|
+
init_windows_acl();
|
|
13646
|
+
init_windows_env_file();
|
|
13052
13647
|
SERVICE_REGISTRY_PROBE_TIMEOUT_MS = 1e4;
|
|
13648
|
+
ERROR_SERVICE_NOT_ACTIVE = 1062;
|
|
13649
|
+
ERROR_SERVICE_CANNOT_ACCEPT_CTRL = 1061;
|
|
13650
|
+
BATCH_WRAPPER_ARGS = [
|
|
13651
|
+
"/d",
|
|
13652
|
+
"/e:on",
|
|
13653
|
+
"/v:off",
|
|
13654
|
+
"/c",
|
|
13655
|
+
"call"
|
|
13656
|
+
];
|
|
13657
|
+
BATCH_STDIN = "<NUL";
|
|
13053
13658
|
WindowsServiceManager = class {
|
|
13054
13659
|
platform = "windows";
|
|
13055
13660
|
/**
|
|
@@ -13091,13 +13696,24 @@ var init_windows = __esmMin((() => {
|
|
|
13091
13696
|
return false;
|
|
13092
13697
|
}
|
|
13093
13698
|
}
|
|
13699
|
+
/** The service's STATE code from `sc.exe query`, or undefined when the query fails. */
|
|
13700
|
+
queryStateCode(name) {
|
|
13701
|
+
try {
|
|
13702
|
+
return parseStateCode(execSync(`sc.exe query ${name}`, { stdio: "pipe" }).toString());
|
|
13703
|
+
} catch {
|
|
13704
|
+
return;
|
|
13705
|
+
}
|
|
13706
|
+
}
|
|
13094
13707
|
async install(config) {
|
|
13708
|
+
const command = serviceCommand(config);
|
|
13709
|
+
if (!launchReadsEnvFile(config)) throw new Error(`cannot register ${config.name}: ` + envFileRefusal(launchedRelease(config), config.envFilePath));
|
|
13710
|
+
const envDir = path.dirname(config.envFilePath);
|
|
13711
|
+
fs.mkdirSync(envDir, { recursive: true });
|
|
13712
|
+
if (!config.isUserLevel) restrictEnvFileAccess(config.envFilePath);
|
|
13095
13713
|
if (this.serviceExists(config.name)) await this.uninstall(config);
|
|
13096
13714
|
const depMeta = getDepMetadata("shawl");
|
|
13097
13715
|
const depPath = await ensureDep(depMeta, getCacheDir());
|
|
13098
13716
|
const shawlExe = path.join(depPath, depMeta.extractPath);
|
|
13099
|
-
const envDir = path.dirname(config.envFilePath);
|
|
13100
|
-
fs.mkdirSync(envDir, { recursive: true });
|
|
13101
13717
|
const cwd = config.workingDirectory.replace(/\\+$/, "");
|
|
13102
13718
|
const logDir = path.join(envDir, "logs");
|
|
13103
13719
|
fs.mkdirSync(logDir, { recursive: true });
|
|
@@ -13109,23 +13725,12 @@ var init_windows = __esmMin((() => {
|
|
|
13109
13725
|
"--cwd",
|
|
13110
13726
|
`"${cwd}"`,
|
|
13111
13727
|
"--log-dir",
|
|
13112
|
-
`"${logDir.replace(/\\+$/, "")}"
|
|
13728
|
+
`"${logDir.replace(/\\+$/, "")}"`,
|
|
13729
|
+
"--stop-timeout",
|
|
13730
|
+
String(shutdownGraceSeconds(config) * 1e3)
|
|
13113
13731
|
];
|
|
13114
|
-
|
|
13115
|
-
|
|
13116
|
-
for (const line of envContent.split("\n")) {
|
|
13117
|
-
const trimmed = line.trim();
|
|
13118
|
-
if (trimmed && !trimmed.startsWith("#") && trimmed.includes("=")) {
|
|
13119
|
-
const eqIdx = trimmed.indexOf("=");
|
|
13120
|
-
if (trimmed.slice(0, eqIdx) === "PATH") {
|
|
13121
|
-
const dirs = trimmed.slice(eqIdx + 1).split(";").filter(Boolean);
|
|
13122
|
-
for (const dir of dirs) cmdParts.push("--path-prepend", `"${dir.replace(/\\+$/, "")}"`);
|
|
13123
|
-
} else cmdParts.push("--env", `"${trimmed}"`);
|
|
13124
|
-
}
|
|
13125
|
-
}
|
|
13126
|
-
}
|
|
13127
|
-
cmdParts.push("--", `"${config.executablePath}"`);
|
|
13128
|
-
for (const arg of config.args ?? []) cmdParts.push(`"${arg}"`);
|
|
13732
|
+
cmdParts.push("--env", `"${SERVICE_ENV_FILE_VAR}=${config.envFilePath}"`);
|
|
13733
|
+
cmdParts.push("--", ...command.map((token) => `"${token}"`));
|
|
13129
13734
|
const cmd = cmdParts.join(" ");
|
|
13130
13735
|
execSync(cmd, { stdio: "pipe" });
|
|
13131
13736
|
const dirSuffix = config.component && config.instanceDir ? ` [KiCI-DIR:${config.instanceDir}]` : "";
|
|
@@ -13137,60 +13742,55 @@ var init_windows = __esmMin((() => {
|
|
|
13137
13742
|
execSync(`sc.exe failure ${config.name} reset= ${resetSeconds} actions= ${actions}`, { stdio: "pipe" });
|
|
13138
13743
|
}
|
|
13139
13744
|
async uninstall(config) {
|
|
13745
|
+
const waitSeconds = stopWaitSeconds(config);
|
|
13140
13746
|
try {
|
|
13141
13747
|
execSync(`sc.exe stop ${config.name}`, { stdio: "pipe" });
|
|
13142
|
-
await new Promise((r) => setTimeout(r, 3e3));
|
|
13143
13748
|
} catch {}
|
|
13749
|
+
const stopDeadline = Date.now() + waitSeconds * 1e3;
|
|
13750
|
+
while (Date.now() < stopDeadline) {
|
|
13751
|
+
const stateCode = this.queryStateCode(config.name);
|
|
13752
|
+
if (stateCode === 1 || stateCode === void 0) break;
|
|
13753
|
+
await sleep(500);
|
|
13754
|
+
}
|
|
13144
13755
|
execSync(`sc.exe delete ${config.name}`, { stdio: "pipe" });
|
|
13145
|
-
const deadline = Date.now() +
|
|
13146
|
-
while (Date.now() < deadline)
|
|
13147
|
-
|
|
13148
|
-
await
|
|
13149
|
-
} catch {
|
|
13150
|
-
return;
|
|
13756
|
+
const deadline = Date.now() + waitSeconds * 1e3;
|
|
13757
|
+
while (Date.now() < deadline) {
|
|
13758
|
+
if (!this.serviceExists(config.name)) return;
|
|
13759
|
+
await sleep(1e3);
|
|
13151
13760
|
}
|
|
13152
|
-
throw new Error(`Service ${config.name} still exists after
|
|
13761
|
+
throw new Error(`Service ${config.name} still exists ${waitSeconds}s after sc.exe delete. The process may be hung — check Task Manager and kill manually if needed.`);
|
|
13153
13762
|
}
|
|
13154
13763
|
async start(config) {
|
|
13155
|
-
const deadline = Date.now() +
|
|
13764
|
+
const deadline = Date.now() + stopWaitSeconds(config) * 1e3;
|
|
13156
13765
|
while (Date.now() < deadline) {
|
|
13157
|
-
|
|
13158
|
-
try {
|
|
13159
|
-
stateCode = parseStateCode(execSync(`sc.exe query ${config.name}`, { stdio: "pipe" }).toString());
|
|
13160
|
-
} catch {
|
|
13161
|
-
stateCode = void 0;
|
|
13162
|
-
}
|
|
13766
|
+
const stateCode = this.queryStateCode(config.name);
|
|
13163
13767
|
if (stateCode === 4) return;
|
|
13164
13768
|
if (stateCode === 1) break;
|
|
13165
|
-
await
|
|
13769
|
+
await sleep(500);
|
|
13166
13770
|
}
|
|
13167
13771
|
execSync(`sc.exe start ${config.name}`, { stdio: "pipe" });
|
|
13168
13772
|
}
|
|
13169
13773
|
async stop(config) {
|
|
13170
|
-
execSync(`sc.exe stop ${config.name}`, { stdio: "pipe" });
|
|
13171
|
-
const deadline = Date.now() + 3e4;
|
|
13172
|
-
while (Date.now() < deadline) {
|
|
13173
|
-
let stateCode;
|
|
13174
|
-
try {
|
|
13175
|
-
stateCode = parseStateCode(execSync(`sc.exe query ${config.name}`, { stdio: "pipe" }).toString());
|
|
13176
|
-
} catch {
|
|
13177
|
-
stateCode = void 0;
|
|
13178
|
-
}
|
|
13179
|
-
if (stateCode === 1) return;
|
|
13180
|
-
await new Promise((r) => setTimeout(r, 500));
|
|
13181
|
-
}
|
|
13182
|
-
throw new Error(`Service ${config.name} did not reach STOPPED state within 30s after sc.exe stop. A subsequent start would fail with error 1056 — the service may be hung.`);
|
|
13183
|
-
}
|
|
13184
|
-
async restart(config) {
|
|
13185
13774
|
try {
|
|
13186
13775
|
execSync(`sc.exe stop ${config.name}`, { stdio: "pipe" });
|
|
13187
|
-
} catch {
|
|
13188
|
-
|
|
13776
|
+
} catch (err) {
|
|
13777
|
+
const status = err?.status;
|
|
13778
|
+
if (status !== ERROR_SERVICE_NOT_ACTIVE && status !== ERROR_SERVICE_CANNOT_ACCEPT_CTRL) throw err;
|
|
13779
|
+
const stateCode = this.queryStateCode(config.name);
|
|
13780
|
+
if (stateCode === 1) return;
|
|
13781
|
+
if (status !== ERROR_SERVICE_CANNOT_ACCEPT_CTRL || stateCode !== 3) throw err;
|
|
13782
|
+
}
|
|
13783
|
+
const waitSeconds = stopWaitSeconds(config);
|
|
13784
|
+
const deadline = Date.now() + waitSeconds * 1e3;
|
|
13189
13785
|
while (Date.now() < deadline) {
|
|
13190
|
-
if (
|
|
13191
|
-
await
|
|
13786
|
+
if (this.queryStateCode(config.name) === 1) return;
|
|
13787
|
+
await sleep(500);
|
|
13192
13788
|
}
|
|
13193
|
-
|
|
13789
|
+
throw new Error(`Service ${config.name} did not reach STOPPED state within ${waitSeconds}s after sc.exe stop. A subsequent start would fail with error 1056 — the service may be hung.`);
|
|
13790
|
+
}
|
|
13791
|
+
async restart(config) {
|
|
13792
|
+
await this.stop(config);
|
|
13793
|
+
await this.start(config);
|
|
13194
13794
|
}
|
|
13195
13795
|
async status(config) {
|
|
13196
13796
|
try {
|
|
@@ -13259,18 +13859,38 @@ var init_windows = __esmMin((() => {
|
|
|
13259
13859
|
}
|
|
13260
13860
|
return out;
|
|
13261
13861
|
}
|
|
13262
|
-
|
|
13263
|
-
|
|
13862
|
+
/**
|
|
13863
|
+
* The command line a service is registered with.
|
|
13864
|
+
*
|
|
13865
|
+
* `sc.exe qc` answers most registrations, but it fails (error 1734) once the
|
|
13866
|
+
* line is longer than about 4000 characters, as an older CLI's registration
|
|
13867
|
+
* with every env-file value on it can be. It also prints in the console code
|
|
13868
|
+
* page, so a path outside ASCII, such as one under the profile of a user
|
|
13869
|
+
* named Jürgen, decodes to U+FFFD. In either case the registry value it reads
|
|
13870
|
+
* (`ImagePath`) is read through PowerShell, printed as UTF-8.
|
|
13871
|
+
*/
|
|
13872
|
+
registeredCommandLine(name) {
|
|
13873
|
+
try {
|
|
13874
|
+
const line = execSync(`sc.exe qc ${name}`, { stdio: "pipe" }).toString().match(/BINARY_PATH_NAME\s*:\s*(.+)/)?.[1]?.trim();
|
|
13875
|
+
if (line && !line.includes("�")) return line;
|
|
13876
|
+
} catch {}
|
|
13877
|
+
const key = `HKLM:\\SYSTEM\\CurrentControlSet\\Services\\${name.replace(/'/g, "''")}`;
|
|
13264
13878
|
try {
|
|
13265
|
-
|
|
13879
|
+
return execSync(`powershell -NoProfile -NonInteractive -Command "[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding \$false; (Get-ItemProperty -LiteralPath '${key}').ImagePath"`, { stdio: "pipe" }).toString("utf-8").replace(/^\uFEFF/, "").trim() || null;
|
|
13266
13880
|
} catch {
|
|
13267
13881
|
return null;
|
|
13268
13882
|
}
|
|
13269
|
-
|
|
13270
|
-
|
|
13271
|
-
const
|
|
13883
|
+
}
|
|
13884
|
+
async readLaunchSpec(config) {
|
|
13885
|
+
const line = this.registeredCommandLine(config.name);
|
|
13886
|
+
if (line === null) return null;
|
|
13887
|
+
const parts = [...line.matchAll(/"([^"]*)"|(\S+)/g)].map((t) => ({
|
|
13888
|
+
text: t[1] ?? t[2],
|
|
13889
|
+
quoted: t[1] !== void 0
|
|
13890
|
+
}));
|
|
13891
|
+
const sep = parts.findIndex((p) => !p.quoted && p.text === "--");
|
|
13272
13892
|
if (sep === -1) return null;
|
|
13273
|
-
const tokens =
|
|
13893
|
+
const tokens = unwrapServiceCommand(parts.slice(sep + 1).map((p) => p.text));
|
|
13274
13894
|
if (tokens.length === 0 || !tokens[0]) return null;
|
|
13275
13895
|
return {
|
|
13276
13896
|
execPath: tokens[0],
|
|
@@ -13295,56 +13915,6 @@ function composeFilePath(envFilePath, serviceName) {
|
|
|
13295
13915
|
}
|
|
13296
13916
|
var init_compose_path = __esmMin((() => {}));
|
|
13297
13917
|
//#endregion
|
|
13298
|
-
//#region src/cli/service/image-digests.ts
|
|
13299
|
-
/**
|
|
13300
|
-
* Resolves the customer installer's image reference from the digest record
|
|
13301
|
-
* shipped with this package (installer-image-digests.json at the package root).
|
|
13302
|
-
*
|
|
13303
|
-
* Emits a manifest-list-digest-pinned ref so a `docker`/`podman pull` verifies
|
|
13304
|
-
* the image hash — a registry that serves substituted bits fails the pull. When
|
|
13305
|
-
* the record is missing (a dev tree that never ran a release) or lacks the
|
|
13306
|
-
* image, falls back to the moving `:latest` tag and warns.
|
|
13307
|
-
*
|
|
13308
|
-
* The record resolves relative to this module's directory and works for both
|
|
13309
|
-
* layouts: the source tree (`src/cli/service/` → the JSON is three dirs up at
|
|
13310
|
-
* the package root) and the single-file bundle (`dist/cli.js` → one dir up).
|
|
13311
|
-
*/
|
|
13312
|
-
/**
|
|
13313
|
-
* Climb from a starting directory looking for installer-image-digests.json.
|
|
13314
|
-
* Works for the source layout (src/cli/service → 3 up) and the single-file
|
|
13315
|
-
* bundle layout (dist → 1 up).
|
|
13316
|
-
*/
|
|
13317
|
-
function findRecordFile(startDir) {
|
|
13318
|
-
let dir = startDir;
|
|
13319
|
-
for (let i = 0; i < 6; i++) {
|
|
13320
|
-
const candidate = path.join(dir, RECORD_FILENAME);
|
|
13321
|
-
if (existsSync(candidate)) return candidate;
|
|
13322
|
-
const parent = path.dirname(dir);
|
|
13323
|
-
if (parent === dir) break;
|
|
13324
|
-
dir = parent;
|
|
13325
|
-
}
|
|
13326
|
-
return null;
|
|
13327
|
-
}
|
|
13328
|
-
/**
|
|
13329
|
-
* Resolve `quay.io/kici-dev/<name>:<version>@sha256:<digest>` for an installer
|
|
13330
|
-
* image. Falls back to `:latest` (with a warning) when no recorded digest exists.
|
|
13331
|
-
*/
|
|
13332
|
-
function resolveImageRef(name, opts = {}) {
|
|
13333
|
-
const file = opts.filePath ?? findRecordFile(import.meta.dirname);
|
|
13334
|
-
if (file && existsSync(file)) try {
|
|
13335
|
-
const rec = JSON.parse(readFileSync(file, "utf8"));
|
|
13336
|
-
const digest = rec.images?.[name];
|
|
13337
|
-
if (digest && rec.version) return `${QUAY_PREFIX}/${name}:${rec.version}@${digest}`;
|
|
13338
|
-
} catch {}
|
|
13339
|
-
console.warn(`[kici] no recorded manifest-list digest for ${name}; pinning the mutable :latest tag. Reinstall from a released kici-admin to get a digest-pinned image.`);
|
|
13340
|
-
return `${QUAY_PREFIX}/${name}:latest`;
|
|
13341
|
-
}
|
|
13342
|
-
var QUAY_PREFIX, RECORD_FILENAME;
|
|
13343
|
-
var init_image_digests = __esmMin((() => {
|
|
13344
|
-
QUAY_PREFIX = "quay.io/kici-dev";
|
|
13345
|
-
RECORD_FILENAME = "installer-image-digests.json";
|
|
13346
|
-
}));
|
|
13347
|
-
//#endregion
|
|
13348
13918
|
//#region src/cli/service/compose.ts
|
|
13349
13919
|
/**
|
|
13350
13920
|
* Docker/Podman Compose service manager.
|
|
@@ -13453,29 +14023,6 @@ function getRestartMode(config) {
|
|
|
13453
14023
|
return "unless-stopped";
|
|
13454
14024
|
}
|
|
13455
14025
|
/**
|
|
13456
|
-
* Seconds a runtime must wait after SIGTERM before it SIGKILLs the container.
|
|
13457
|
-
*
|
|
13458
|
-
* Compose defaults this to 10s, which is shorter than either component's own
|
|
13459
|
-
* graceful-shutdown budget, so a teardown that takes longer than 10s is killed
|
|
13460
|
-
* partway through: the orchestrator never broadcasts `peer.leaving` and never
|
|
13461
|
-
* closes its agent sockets, and the agent never finishes draining. That window
|
|
13462
|
-
* is reached exactly when it matters most — the orchestrator's scaler stops
|
|
13463
|
-
* each managed agent container with its own grace, so an orchestrator with
|
|
13464
|
-
* agents in flight routinely needs more than 10s.
|
|
13465
|
-
*
|
|
13466
|
-
* Each value is its component's force-exit budget plus headroom, so the
|
|
13467
|
-
* process always reaches its own timer first and the runtime's kill stays the
|
|
13468
|
-
* last resort it is meant to be:
|
|
13469
|
-
* - orchestrator: 30s (`setupGracefulShutdown` default) -> 45s
|
|
13470
|
-
* - agent: 10s plus a ~1s abort delay in its `onForceExit` -> 20s
|
|
13471
|
-
*
|
|
13472
|
-
* An unrecognised component gets the larger value: over-waiting costs a few
|
|
13473
|
-
* seconds on shutdown, under-waiting corrupts it.
|
|
13474
|
-
*/
|
|
13475
|
-
function stopGracePeriodSeconds(config) {
|
|
13476
|
-
return (config.component ?? (config.name === "kici-agent" ? "agent" : "orchestrator")) === "agent" ? 20 : 45;
|
|
13477
|
-
}
|
|
13478
|
-
/**
|
|
13479
14026
|
* Generate compose YAML content for a service.
|
|
13480
14027
|
*
|
|
13481
14028
|
* Uses a manual YAML builder to avoid adding a YAML library dependency.
|
|
@@ -13483,25 +14030,24 @@ function stopGracePeriodSeconds(config) {
|
|
|
13483
14030
|
* restart policy, and volume mounts.
|
|
13484
14031
|
*
|
|
13485
14032
|
* The `image:` line is pinned by manifest-list digest via `resolveImageRef`
|
|
13486
|
-
* (`quay.io/kici-dev
|
|
13487
|
-
* to the mutable `:latest` tag only when no
|
|
13488
|
-
* package.
|
|
13489
|
-
*
|
|
13490
|
-
* .claude/rules/container-publish-registry.md — Quay is the sole registry for
|
|
13491
|
-
* public KiCI images we publish.
|
|
14033
|
+
* (`quay.io/kici-dev/kici-<component>:<version>@sha256:<digest>`, whatever the
|
|
14034
|
+
* service is named), falling back to the mutable `:latest` tag only when no
|
|
14035
|
+
* recorded digest ships with this package. Quay is the only registry the KiCI
|
|
14036
|
+
* images are published to.
|
|
13492
14037
|
*/
|
|
13493
14038
|
function generateComposeYaml(config) {
|
|
13494
14039
|
const restartMode = getRestartMode(config);
|
|
14040
|
+
const image = config.component ? installerImageName(config.component) : config.name;
|
|
13495
14041
|
const lines = [
|
|
13496
14042
|
"# Generated by KiCI service installer",
|
|
13497
14043
|
`# Service: ${config.displayName}`,
|
|
13498
14044
|
"",
|
|
13499
14045
|
"services:",
|
|
13500
14046
|
` ${config.name}:`,
|
|
13501
|
-
` image: ${resolveImageRef(
|
|
14047
|
+
` image: ${resolveImageRef(image)}`,
|
|
13502
14048
|
` container_name: ${config.name}`,
|
|
13503
14049
|
` restart: ${restartMode}`,
|
|
13504
|
-
` stop_grace_period: ${
|
|
14050
|
+
` stop_grace_period: ${shutdownGraceSeconds(config)}s`,
|
|
13505
14051
|
" env_file:",
|
|
13506
14052
|
` - ${config.envFilePath}`,
|
|
13507
14053
|
" volumes:",
|
|
@@ -13519,6 +14065,7 @@ var RUNTIME_PROBE_TIMEOUT_MS, ComposeServiceManager;
|
|
|
13519
14065
|
var init_compose = __esmMin((() => {
|
|
13520
14066
|
init_compose_path();
|
|
13521
14067
|
init_image_digests();
|
|
14068
|
+
init_shutdown_grace();
|
|
13522
14069
|
RUNTIME_PROBE_TIMEOUT_MS = 1e4;
|
|
13523
14070
|
ComposeServiceManager = class {
|
|
13524
14071
|
platform = "compose";
|
|
@@ -13673,11 +14220,14 @@ var init_compose = __esmMin((() => {
|
|
|
13673
14220
|
var service_exports = /* @__PURE__ */ __exportAll({
|
|
13674
14221
|
COMPONENTS: () => COMPONENTS,
|
|
13675
14222
|
DEFAULT_RESTART_POLICY: () => DEFAULT_RESTART_POLICY,
|
|
14223
|
+
ENV_FILE_LOADER_EXPORT: () => ENV_FILE_LOADER_EXPORT,
|
|
13676
14224
|
InstanceNotFoundError: () => InstanceNotFoundError,
|
|
14225
|
+
SERVICE_TEXT: () => SERVICE_TEXT,
|
|
13677
14226
|
appendIndexEntry: () => appendIndexEntry,
|
|
13678
14227
|
candidatePlatforms: () => candidatePlatforms,
|
|
13679
14228
|
createServiceManager: () => createServiceManager,
|
|
13680
14229
|
detectPlatform: () => detectPlatform,
|
|
14230
|
+
envFileRefusal: () => envFileRefusal,
|
|
13681
14231
|
formatRefusal: () => formatRefusal,
|
|
13682
14232
|
getCacheDir: () => getCacheDir,
|
|
13683
14233
|
getConfigDir: () => getConfigDir,
|
|
@@ -13686,6 +14236,8 @@ var service_exports = /* @__PURE__ */ __exportAll({
|
|
|
13686
14236
|
isComponent: () => isComponent,
|
|
13687
14237
|
isRoot: () => isRoot,
|
|
13688
14238
|
kiciConfigRoot: () => kiciConfigRoot,
|
|
14239
|
+
launchReadsEnvFile: () => launchReadsEnvFile,
|
|
14240
|
+
launchedRelease: () => launchedRelease,
|
|
13689
14241
|
listInstances: () => listInstances,
|
|
13690
14242
|
manifestFilename: () => manifestFilename,
|
|
13691
14243
|
manifestPath: () => manifestPath,
|
|
@@ -13695,10 +14247,13 @@ var service_exports = /* @__PURE__ */ __exportAll({
|
|
|
13695
14247
|
removeIndexEntry: () => removeIndexEntry,
|
|
13696
14248
|
resolveInstance: () => resolveInstance,
|
|
13697
14249
|
resolveInstanceTarget: () => resolveInstanceTarget,
|
|
14250
|
+
resolveLauncherVersion: () => resolveLauncherVersion,
|
|
13698
14251
|
resolveNpmInstallTarget: () => resolveNpmInstallTarget,
|
|
13699
14252
|
resolveUserLevel: () => resolveUserLevel,
|
|
13700
14253
|
resolveVersionFromLaunchSpec: () => resolveVersionFromLaunchSpec,
|
|
14254
|
+
restrictEnvFileAccess: () => restrictEnvFileAccess,
|
|
13701
14255
|
writeIndex: () => writeIndex,
|
|
14256
|
+
writeInstallManifest: () => writeInstallManifest,
|
|
13702
14257
|
writeManifest: () => writeManifest
|
|
13703
14258
|
});
|
|
13704
14259
|
/**
|
|
@@ -13736,6 +14291,8 @@ var init_service = __esmMin((() => {
|
|
|
13736
14291
|
init_types$1();
|
|
13737
14292
|
init_platform_detect();
|
|
13738
14293
|
init_privilege();
|
|
14294
|
+
init_windows_acl();
|
|
14295
|
+
init_windows_env_file();
|
|
13739
14296
|
init_types();
|
|
13740
14297
|
init_manifest();
|
|
13741
14298
|
init_index_file();
|
|
@@ -13768,7 +14325,7 @@ function resolveDatabaseUrl$7(explicit) {
|
|
|
13768
14325
|
if (!url) throw new Error("Database URL required. Pass --database-url or set KICI_DATABASE_URL.");
|
|
13769
14326
|
return url;
|
|
13770
14327
|
}
|
|
13771
|
-
async function confirmInteractive$
|
|
14328
|
+
async function confirmInteractive$1(prompt, expected) {
|
|
13772
14329
|
if (!process.stdin.isTTY) return false;
|
|
13773
14330
|
process.stderr.write(prompt);
|
|
13774
14331
|
const rl = createInterface({
|
|
@@ -13896,7 +14453,7 @@ function registerDbCommands(program, getClient) {
|
|
|
13896
14453
|
const url = resolveDatabaseUrl$7(opts.databaseUrl);
|
|
13897
14454
|
const { dbName } = parseDatabaseUrl(url);
|
|
13898
14455
|
if (!opts.yes) {
|
|
13899
|
-
if (!await confirmInteractive$
|
|
14456
|
+
if (!await confirmInteractive$1(`About to DROP + RECREATE database "${dbName}". Type "${dbName}" to confirm: `, dbName)) {
|
|
13900
14457
|
console.error("Aborted.");
|
|
13901
14458
|
process.exit(1);
|
|
13902
14459
|
}
|
|
@@ -13948,7 +14505,7 @@ function registerDbCommands(program, getClient) {
|
|
|
13948
14505
|
const url = resolveDatabaseUrl$7(opts.databaseUrl);
|
|
13949
14506
|
const { dbName } = parseDatabaseUrl(url);
|
|
13950
14507
|
if (!opts.yes) {
|
|
13951
|
-
if (!await confirmInteractive$
|
|
14508
|
+
if (!await confirmInteractive$1(`About to RESTORE (--clean) into database "${dbName}", overwriting its contents. Type "${dbName}" to confirm: `, dbName)) {
|
|
13952
14509
|
console.error("Aborted.");
|
|
13953
14510
|
process.exit(1);
|
|
13954
14511
|
}
|
|
@@ -15087,7 +15644,7 @@ function startManifestLoopback(opts) {
|
|
|
15087
15644
|
* blocked launcher) just falls back to copy-paste.
|
|
15088
15645
|
*
|
|
15089
15646
|
* `KICI_BROWSER_CMD` overrides the launcher: `none` suppresses it entirely
|
|
15090
|
-
* (
|
|
15647
|
+
* (automated tests / headless capture), any other value is run with `{url}` substituted —
|
|
15091
15648
|
* mirroring the `kici login` convention.
|
|
15092
15649
|
*/
|
|
15093
15650
|
function openBrowserBestEffort(url) {
|
|
@@ -16007,19 +16564,9 @@ function registerSourceCommands(program, getClient) {
|
|
|
16007
16564
|
});
|
|
16008
16565
|
src.command("remove <routingKey>").description("Remove a source (GitHub, or generic/local with --generic / --local)").option("--yes", "Skip confirmation").option("--generic", "Remove a generic source (routingKey is treated as source ID)").option("--local", "Remove a local (file://) source (routingKey is treated as source ID)").option("--hard", "Permanently delete a generic/local source (requires --generic or --local)").option("--database-url <url>", "Use direct DB access instead of HTTP (offline mode)").action(async (routingKey, opts) => {
|
|
16009
16566
|
try {
|
|
16010
|
-
if (!opts.yes) {
|
|
16011
|
-
|
|
16012
|
-
|
|
16013
|
-
output: process.stdout
|
|
16014
|
-
});
|
|
16015
|
-
const answer = await new Promise((resolve) => {
|
|
16016
|
-
rl.question(`Remove source "${routingKey}"? (y/N) `, resolve);
|
|
16017
|
-
});
|
|
16018
|
-
rl.close();
|
|
16019
|
-
if (answer.toLowerCase() !== "y") {
|
|
16020
|
-
console.log("Cancelled.");
|
|
16021
|
-
return;
|
|
16022
|
-
}
|
|
16567
|
+
if (!opts.yes && !await confirmPrompt(`Remove source "${routingKey}"? (y/N) `)) {
|
|
16568
|
+
console.log("Cancelled.");
|
|
16569
|
+
return;
|
|
16023
16570
|
}
|
|
16024
16571
|
const dbUrl = resolveDirectDbUrl$9(opts.databaseUrl);
|
|
16025
16572
|
if (dbUrl) {
|
|
@@ -16177,10 +16724,9 @@ function registerRemoteSourceCommands(program) {
|
|
|
16177
16724
|
* Workflow registration commands for kici-admin.
|
|
16178
16725
|
*
|
|
16179
16726
|
* Provides operator-facing read access to the workflow_registrations table
|
|
16180
|
-
* via the orchestrator's admin HTTP API.
|
|
16181
|
-
*
|
|
16182
|
-
*
|
|
16183
|
-
* route.
|
|
16727
|
+
* via the orchestrator's admin HTTP API. Use it instead of querying the
|
|
16728
|
+
* registration table with psql: every CLI subcommand here goes through
|
|
16729
|
+
* AdminApiClient and the existing /api/v1/admin/registrations route.
|
|
16184
16730
|
*
|
|
16185
16731
|
* workflow list List registered workflows (table or JSON)
|
|
16186
16732
|
*
|
|
@@ -16263,7 +16809,7 @@ function renderTable$3(headers, rows) {
|
|
|
16263
16809
|
*/
|
|
16264
16810
|
function registerWorkflowCommands(program, getClient) {
|
|
16265
16811
|
const wf = program.command("workflow").description("Inspect workflow registrations");
|
|
16266
|
-
wf.command("list").description("List workflow registrations (
|
|
16812
|
+
wf.command("list").description("List workflow registrations (admin API: /api/v1/admin/registrations)").option("--org <orgId>", "Filter by customer/org id (server param: customerId)").option("--routing-key <key>", "Filter by routing key, e.g. github:42").option("--repo <ownerRepo>", "Filter by repo identifier (owner/repo)").option("--trigger-type <type>", "Filter by trigger type, e.g. webhook, push, schedule").option("--event <eventName>", "Filter by webhook event name (scans lock_entry.triggers)").option("--json", "Emit raw JSON instead of a table").action(async (opts) => {
|
|
16267
16813
|
try {
|
|
16268
16814
|
const path = `/api/v1/admin/registrations${buildQueryString({
|
|
16269
16815
|
org: opts.org,
|
|
@@ -16387,7 +16933,7 @@ function emptyStepLogNote(stepIndex, logs) {
|
|
|
16387
16933
|
*/
|
|
16388
16934
|
function registerRunsCommands(program, getClient) {
|
|
16389
16935
|
const runs = program.command("runs").description("Inspect execution runs, jobs, and steps");
|
|
16390
|
-
runs.command("list").description("List execution runs (
|
|
16936
|
+
runs.command("list").description("List execution runs (admin API: /api/v1/admin/runs)").option("--status <statuses>", "Filter by run status. Accepts a single value or a comma-separated list (e.g. success,failed)").option("--workflow-name <name>", "Filter by workflow name").option("--repo <ownerRepo>", "Filter by repo identifier (owner/repo)").option("--since <iso8601>", "Only include runs with created_at strictly later than this ISO-8601 timestamp").option("--count", "Return only the count of matching runs, skipping the row listing").option("--limit <n>", "Max results (default 20, max 100)", "20").option("--offset <n>", "Skip first N results", "0").option("--json", "Emit raw JSON instead of a table").action(async (opts) => {
|
|
16391
16937
|
try {
|
|
16392
16938
|
if (opts.count) {
|
|
16393
16939
|
const data = await getClient().countRuns({
|
|
@@ -16590,7 +17136,7 @@ function registerRunsCommands(program, getClient) {
|
|
|
16590
17136
|
process.exit(1);
|
|
16591
17137
|
}
|
|
16592
17138
|
});
|
|
16593
|
-
runs.command("jobs <runId>").description("List jobs for a run (
|
|
17139
|
+
runs.command("jobs <runId>").description("List jobs for a run (admin API: /api/v1/admin/runs/:runId/jobs)").option("--include-steps", "Embed step list inside each job (default false)").option("--json", "Emit raw JSON instead of a table").action(async (runId, opts) => {
|
|
16594
17140
|
try {
|
|
16595
17141
|
const response = await getClient().getRunJobs(runId, { includeSteps: Boolean(opts.includeSteps) });
|
|
16596
17142
|
if (opts.json) {
|
|
@@ -16644,7 +17190,7 @@ function registerRunsCommands(program, getClient) {
|
|
|
16644
17190
|
process.exit(1);
|
|
16645
17191
|
}
|
|
16646
17192
|
});
|
|
16647
|
-
runs.command("logs <runId>").description("Print a page of a step log (
|
|
17193
|
+
runs.command("logs <runId>").description("Print a page of a step log (admin API: /api/v1/admin/runs/:runId/jobs/:jobId/steps/:i/logs)").requiredOption("--job <jobId>", "Job id (the dispatch_queue row id for an eval round)").option("--step <n>", "Step index (default 0; -1 is the job setup log)", "0").option("--limit <n>", "Max lines to return (default 500, server caps at 2000)", "500").option("--cursor <c>", "Line-offset cursor from a previous page").option("--json", "Emit raw JSON instead of plain lines").action(async (runId, opts) => {
|
|
16648
17194
|
try {
|
|
16649
17195
|
const params = new URLSearchParams({ limit: String(opts.limit) });
|
|
16650
17196
|
if (opts.cursor) params.set("cursor", String(opts.cursor));
|
|
@@ -16754,6 +17300,8 @@ function clusterSentinelKey(prefix) {
|
|
|
16754
17300
|
* Provider configuration (GitHub Apps, etc.) is managed via the `sources` table
|
|
16755
17301
|
* and PgSecretStore, not through config. See SourceStore and SourceManager.
|
|
16756
17302
|
*/
|
|
17303
|
+
/** How long the database pool waits for a connection when `KICI_DB_POOL_ACQUIRE_TIMEOUT_MS` is unset. */
|
|
17304
|
+
const DB_POOL_ACQUIRE_TIMEOUT_DEFAULT_MS = 5e3;
|
|
16757
17305
|
/**
|
|
16758
17306
|
* Default for `sealed_secrets_retry_backoff_ms`: how long a coordinator stops
|
|
16759
17307
|
* offering a queued job it put back because it could not open its seal. The
|
|
@@ -16764,7 +17312,7 @@ const DEFAULT_SEALED_SECRETS_RETRY_BACKOFF_MS = 6e4;
|
|
|
16764
17312
|
const PLATFORM_CONNECTED_MODES_TEXT = `${PLATFORM_CONNECTED_MODES.slice(0, -1).join(", ")}, or ${PLATFORM_CONNECTED_MODES[PLATFORM_CONNECTED_MODES.length - 1]}`;
|
|
16765
17313
|
const baseSchema = z.object({
|
|
16766
17314
|
mode: OrchestratorMode.default("platform"),
|
|
16767
|
-
port: z.coerce.number().default(
|
|
17315
|
+
port: z.coerce.number().default(ORCHESTRATOR_DEFAULT_PORT),
|
|
16768
17316
|
/**
|
|
16769
17317
|
* Address the HTTP + WebSocket listener binds. Defaults to every interface,
|
|
16770
17318
|
* matching the Platform's own `KICI_HOST`, because a real orchestrator
|
|
@@ -16841,7 +17389,7 @@ const baseSchema = z.object({
|
|
|
16841
17389
|
orchestratorUrl: z.string().optional(),
|
|
16842
17390
|
databaseUrl: z.string().default(""),
|
|
16843
17391
|
dbPoolMax: z.coerce.number().int().positive().default(20),
|
|
16844
|
-
dbPoolAcquireTimeoutMs: z.coerce.number().int().nonnegative().default(
|
|
17392
|
+
dbPoolAcquireTimeoutMs: z.coerce.number().int().nonnegative().default(DB_POOL_ACQUIRE_TIMEOUT_DEFAULT_MS),
|
|
16845
17393
|
dbStatementTimeoutMs: z.coerce.number().int().nonnegative().default(3e4),
|
|
16846
17394
|
lockfileCacheMax: z.coerce.number().default(500),
|
|
16847
17395
|
lockfileCacheTtlMs: z.coerce.number().default(36e5),
|
|
@@ -17130,7 +17678,7 @@ const configSchema = baseSchema.superRefine((data, ctx) => {
|
|
|
17130
17678
|
const packagingConfigSchema = baseSchema.superRefine(refineStorageShape);
|
|
17131
17679
|
/**
|
|
17132
17680
|
* Env-var definition for the orchestrator. Exported so the docs generator and
|
|
17133
|
-
*
|
|
17681
|
+
* config pre-validation can re-parse without going through process.env.
|
|
17134
17682
|
*
|
|
17135
17683
|
* Note: passes the inner `baseSchema` (a ZodObject) so describe() can walk
|
|
17136
17684
|
* `.shape`, but uses the outer `configSchema` (with .superRefine) as the parser
|
|
@@ -17378,8 +17926,8 @@ const COLD_STORE_ENV_VARS = [
|
|
|
17378
17926
|
"KICI_COLD_STORE_EVENT_LOG_ENABLED"
|
|
17379
17927
|
];
|
|
17380
17928
|
/**
|
|
17381
|
-
* Deployment-identity env vars injected by the installer (
|
|
17382
|
-
*
|
|
17929
|
+
* Deployment-identity env vars injected by the installer (or other deploy
|
|
17930
|
+
* tooling) so the orchestrator can report its own deployment shape in
|
|
17383
17931
|
* `source.register`. Read directly from `process.env` by the deployment reader,
|
|
17384
17932
|
* not threaded through the AppConfig schema — registered here so the
|
|
17385
17933
|
* unknown-KICI_* validator at boot doesn't reject them.
|
|
@@ -17757,7 +18305,8 @@ var S3CacheStorage = class {
|
|
|
17757
18305
|
* Signature input: `${method}:${key}:${expiresUnixMs}` with HMAC-SHA256 keyed
|
|
17758
18306
|
* by a per-orchestrator-process secret (random 32-byte hex at boot, persists
|
|
17759
18307
|
* for the lifetime of the process). Tokens become invalid on orchestrator
|
|
17760
|
-
* restart — fine for the filesystem backend's
|
|
18308
|
+
* restart — fine for the filesystem backend's single-host / test-sandbox use
|
|
18309
|
+
* case.
|
|
17761
18310
|
*/
|
|
17762
18311
|
/** Default URL lifetime: 1 hour (matches S3 backend's PUT-URL lifetime). */
|
|
17763
18312
|
const DEFAULT_SIGN_URL_TTL_MS = 36e5;
|
|
@@ -17793,7 +18342,7 @@ function signToken(secret, method, key, ttlMs = DEFAULT_SIGN_URL_TTL_MS) {
|
|
|
17793
18342
|
* are replaced by HMAC-signed `http://<orchUrl>/api/v1/cache/blob/...` URLs
|
|
17794
18343
|
* — see `sign-url.ts` for the token mechanics and `app.ts` for the route.
|
|
17795
18344
|
*
|
|
17796
|
-
* Intended for single-host deployments and
|
|
18345
|
+
* Intended for single-host deployments and test sandboxes where standing up
|
|
17797
18346
|
* an S3-compatible service is overkill. Production should still use S3.
|
|
17798
18347
|
*
|
|
17799
18348
|
* Metadata is co-located as a sibling JSON file (`<key>.meta.json`) so the
|
|
@@ -18063,7 +18612,7 @@ function createCacheStorage(config) {
|
|
|
18063
18612
|
//#region src/provenance/trust-root.ts
|
|
18064
18613
|
/**
|
|
18065
18614
|
* Derive the JWKS URI for a provenance OIDC issuer. Identical to the Platform's
|
|
18066
|
-
* own derivation
|
|
18615
|
+
* own derivation so a bundle
|
|
18067
18616
|
* verified client-side and at ingest resolve the same key set.
|
|
18068
18617
|
*/
|
|
18069
18618
|
function deriveJwksUri(issuer) {
|
|
@@ -18292,16 +18841,6 @@ function readAttestations(db) {
|
|
|
18292
18841
|
}
|
|
18293
18842
|
//#endregion
|
|
18294
18843
|
//#region src/cli/commands/attestations.ts
|
|
18295
|
-
/**
|
|
18296
|
-
* `kici-admin attestations` — orchestrator-DB-direct provenance maintenance.
|
|
18297
|
-
*
|
|
18298
|
-
* attestations reverify [--all] Recompute stored verdicts (verify-at-ingest
|
|
18299
|
-
* backfill). Default scope: pending /
|
|
18300
|
-
* unverifiable rows; `--all` re-evaluates
|
|
18301
|
-
* every row. Direct DB + object storage.
|
|
18302
|
-
*
|
|
18303
|
-
* Classified `kici-admin` (orchestrator DB plane) per .claude/rules/platform-admin.md.
|
|
18304
|
-
*/
|
|
18305
18844
|
init_client();
|
|
18306
18845
|
const logger$15 = createLogger({ prefix: "kici-admin-attestations" });
|
|
18307
18846
|
function resolveDatabaseUrl$5(explicit) {
|
|
@@ -18330,18 +18869,6 @@ function buildStorage(config) {
|
|
|
18330
18869
|
signingSecret: "kici-admin-reverify"
|
|
18331
18870
|
});
|
|
18332
18871
|
}
|
|
18333
|
-
async function confirmInteractive$3(prompt) {
|
|
18334
|
-
const rl = createInterface({
|
|
18335
|
-
input: process.stdin,
|
|
18336
|
-
output: process.stderr
|
|
18337
|
-
});
|
|
18338
|
-
try {
|
|
18339
|
-
const answer = await new Promise((resolve) => rl.question(prompt, resolve));
|
|
18340
|
-
return answer.trim().toLowerCase() === "y" || answer.trim().toLowerCase() === "yes";
|
|
18341
|
-
} finally {
|
|
18342
|
-
rl.close();
|
|
18343
|
-
}
|
|
18344
|
-
}
|
|
18345
18872
|
/** Render attestation rows as a compact human table (stderr note when empty). */
|
|
18346
18873
|
function printAttestationTable(rows) {
|
|
18347
18874
|
if (rows.length === 0) {
|
|
@@ -18371,7 +18898,7 @@ function registerAttestationsCommands(program, getClient) {
|
|
|
18371
18898
|
const config = loadConfig();
|
|
18372
18899
|
const url = resolveDatabaseUrl$5(opts.databaseUrl ?? (config.databaseUrl || void 0));
|
|
18373
18900
|
if (opts.all && !opts.yes) {
|
|
18374
|
-
if (!await
|
|
18901
|
+
if (!await confirmPrompt("Re-evaluate the verdict of EVERY attestation (including already-verified)? [y/N] ")) {
|
|
18375
18902
|
console.error("Aborted.");
|
|
18376
18903
|
process.exit(1);
|
|
18377
18904
|
}
|
|
@@ -18594,7 +19121,7 @@ var OrchestratorSigningKeyRepo = class {
|
|
|
18594
19121
|
* The in-memory private key is imported NON-EXTRACTABLE (`extractable: false`),
|
|
18595
19122
|
* so once loaded the raw private material can never be read back out of the
|
|
18596
19123
|
* process — the only export path is the public JWK. This is what makes the
|
|
18597
|
-
* private key "non-exportable by design"
|
|
19124
|
+
* private key "non-exportable by design".
|
|
18598
19125
|
*/
|
|
18599
19126
|
/** AAD binding the wrapped private JWK to its purpose (AES-GCM authenticated data). */
|
|
18600
19127
|
const SIGNING_KEY_AAD = "orchestrator-signing-key";
|
|
@@ -18707,8 +19234,7 @@ var DbSigner = class DbSigner {
|
|
|
18707
19234
|
//#region src/cli/commands/signing-key.ts
|
|
18708
19235
|
/**
|
|
18709
19236
|
* `kici-admin signing-key` — orchestrator-owned provenance signing key
|
|
18710
|
-
* management (orchestrator DB plane
|
|
18711
|
-
* .claude/rules/platform-admin.md).
|
|
19237
|
+
* management (orchestrator DB plane, so it belongs to `kici-admin`).
|
|
18712
19238
|
*
|
|
18713
19239
|
* signing-key list List keys (kid / status / created_at).
|
|
18714
19240
|
* Reads the database when a database URL
|
|
@@ -18725,7 +19251,7 @@ var DbSigner = class DbSigner {
|
|
|
18725
19251
|
* halves ONLY — never private material.
|
|
18726
19252
|
*
|
|
18727
19253
|
* The PRIVATE key is non-exportable by design: there is no export of private
|
|
18728
|
-
* material and no import. Loss recovery is a routine rotation
|
|
19254
|
+
* material and no import. Loss recovery is a routine rotation.
|
|
18729
19255
|
*/
|
|
18730
19256
|
init_client();
|
|
18731
19257
|
const logger$14 = createLogger({ prefix: "kici-admin-signing-key" });
|
|
@@ -18766,18 +19292,6 @@ function resolveSecretKey$1() {
|
|
|
18766
19292
|
if (!config.secretKey) throw new Error("KICI_SECRET_KEY is required to generate a db-custody signing key (it wraps the private JWK).");
|
|
18767
19293
|
return config.secretKey;
|
|
18768
19294
|
}
|
|
18769
|
-
async function confirmInteractive$2(prompt) {
|
|
18770
|
-
const rl = createInterface({
|
|
18771
|
-
input: process.stdin,
|
|
18772
|
-
output: process.stderr
|
|
18773
|
-
});
|
|
18774
|
-
try {
|
|
18775
|
-
const a = (await new Promise((resolve) => rl.question(prompt, resolve))).trim().toLowerCase();
|
|
18776
|
-
return a === "y" || a === "yes";
|
|
18777
|
-
} finally {
|
|
18778
|
-
rl.close();
|
|
18779
|
-
}
|
|
18780
|
-
}
|
|
18781
19295
|
async function withRepo$1(databaseUrl, fn) {
|
|
18782
19296
|
const pool = createPool(resolveDatabaseUrl$4(databaseUrl));
|
|
18783
19297
|
const db = createDb$1(pool);
|
|
@@ -18869,7 +19383,7 @@ function registerSigningKeyCommands(program, tryGetClient) {
|
|
|
18869
19383
|
process.stderr.write(`Generated active signing key ${outcome.kid}.\n`);
|
|
18870
19384
|
}
|
|
18871
19385
|
}, {
|
|
18872
|
-
confirm:
|
|
19386
|
+
confirm: confirmPrompt,
|
|
18873
19387
|
yes: opts.yes,
|
|
18874
19388
|
dryRun: opts.dryRun
|
|
18875
19389
|
});
|
|
@@ -18893,7 +19407,7 @@ function registerSigningKeyCommands(program, tryGetClient) {
|
|
|
18893
19407
|
process.stderr.write(`Rotated: new active signing key ${kid}.\n`);
|
|
18894
19408
|
}
|
|
18895
19409
|
}, {
|
|
18896
|
-
confirm:
|
|
19410
|
+
confirm: confirmPrompt,
|
|
18897
19411
|
yes: opts.yes,
|
|
18898
19412
|
dryRun: opts.dryRun
|
|
18899
19413
|
});
|
|
@@ -18915,7 +19429,7 @@ function registerSigningKeyCommands(program, tryGetClient) {
|
|
|
18915
19429
|
signingKey.command("revoke <kid>").description("Distrust a compromised key (REMOVED from the JWKS; everything it signed is distrusted)").requiredOption("--reason <reason>", "Why the key is being revoked (audit)").option("--database-url <url>", "Orchestrator DB URL (else KICI_DATABASE_URL)").option("--yes", "Skip the confirmation prompt").action(async (kid, opts) => {
|
|
18916
19430
|
try {
|
|
18917
19431
|
if (!opts.yes) {
|
|
18918
|
-
if (!await
|
|
19432
|
+
if (!await confirmPrompt(`Revoke signing key ${kid}? Everything it ever signed becomes UNVERIFIABLE. [y/N] `)) {
|
|
18919
19433
|
console.error("Aborted.");
|
|
18920
19434
|
process.exit(1);
|
|
18921
19435
|
}
|
|
@@ -19261,6 +19775,41 @@ function encryptPrivateKey(privateKey, secretKey) {
|
|
|
19261
19775
|
}
|
|
19262
19776
|
createLogger({ prefix: "job-secret-seal" });
|
|
19263
19777
|
runtimeLabel(RuntimeFact.enum.docker), runtimeLabel(RuntimeFact.enum.podman);
|
|
19778
|
+
//#endregion
|
|
19779
|
+
//#region src/helpers/run-detached.ts
|
|
19780
|
+
/**
|
|
19781
|
+
* Running work that nothing awaits.
|
|
19782
|
+
*
|
|
19783
|
+
* A promise that nobody awaits and nobody catches becomes an unhandled
|
|
19784
|
+
* rejection when it fails, and the orchestrator's shutdown hook treats an
|
|
19785
|
+
* unhandled rejection as fatal: one failed query in a timer tick or a message
|
|
19786
|
+
* handler would stop the whole process. `runDetached` runs such work and logs
|
|
19787
|
+
* its failure instead. ESLint's `no-floating-promises` (with `ignoreVoid:
|
|
19788
|
+
* false`) refuses a bare or `void`-ed promise anywhere in this package, so
|
|
19789
|
+
* detached work runs through here or carries its own `.catch`.
|
|
19790
|
+
*/
|
|
19791
|
+
/**
|
|
19792
|
+
* Run `work` without waiting for it. A throw, or a rejection of the promise it
|
|
19793
|
+
* returns, is logged at error level as `<task> failed` with `context` and the
|
|
19794
|
+
* error message, and goes no further. `context` is read when the failure is
|
|
19795
|
+
* logged, so the work may fill it in while it runs.
|
|
19796
|
+
*/
|
|
19797
|
+
function runDetached(logger, task, work, context = {}) {
|
|
19798
|
+
const fail = (err) => {
|
|
19799
|
+
logger.error(`${task} failed`, {
|
|
19800
|
+
...context,
|
|
19801
|
+
error: toErrorMessage(err)
|
|
19802
|
+
});
|
|
19803
|
+
};
|
|
19804
|
+
let outcome;
|
|
19805
|
+
try {
|
|
19806
|
+
outcome = work();
|
|
19807
|
+
} catch (err) {
|
|
19808
|
+
fail(err);
|
|
19809
|
+
return;
|
|
19810
|
+
}
|
|
19811
|
+
Promise.resolve(outcome).catch(fail);
|
|
19812
|
+
}
|
|
19264
19813
|
createLogger({ prefix: "instance-heartbeat" });
|
|
19265
19814
|
createLogger({ prefix: "job-queue" });
|
|
19266
19815
|
/**
|
|
@@ -19287,24 +19836,6 @@ async function generateDashboardEncryptionKey(secretKey) {
|
|
|
19287
19836
|
}
|
|
19288
19837
|
//#endregion
|
|
19289
19838
|
//#region src/cli/commands/dashboard-encryption-key.ts
|
|
19290
|
-
/**
|
|
19291
|
-
* `kici-admin dashboard-encryption-key` — the orchestrator's X25519
|
|
19292
|
-
* dashboard-encryption key: the trust root a browser seals a secret / variable
|
|
19293
|
-
* value to under the `encrypted` dashboard-write posture, so the hosted control
|
|
19294
|
-
* plane relays only opaque ciphertext.
|
|
19295
|
-
*
|
|
19296
|
-
* dashboard-encryption-key show Print the active kid, its public JWK, where
|
|
19297
|
-
* the JWKS is published, and the Verified-tier
|
|
19298
|
-
* URL (when that tier is opted into).
|
|
19299
|
-
* dashboard-encryption-key list List every key on record (kid / status / created_at).
|
|
19300
|
-
* dashboard-encryption-key rotate Mint a new active key; the prior active key
|
|
19301
|
-
* is retired from the published JWKS, but its
|
|
19302
|
-
* private half stays on record so envelopes
|
|
19303
|
-
* already sealed to it still decrypt.
|
|
19304
|
-
*
|
|
19305
|
-
* The PRIVATE half is master-key wrapped (`KICI_SECRET_KEY`) in the orchestrator
|
|
19306
|
-
* DB and is never exportable — recovery from a lost key is a routine rotation.
|
|
19307
|
-
*/
|
|
19308
19839
|
init_client();
|
|
19309
19840
|
const logger$9 = createLogger({ prefix: "kici-admin-dashboard-encryption-key" });
|
|
19310
19841
|
function resolveDatabaseUrl$3(explicit) {
|
|
@@ -19317,18 +19848,6 @@ function resolveSecretKey() {
|
|
|
19317
19848
|
if (!config.secretKey) throw new Error("KICI_SECRET_KEY is required for the dashboard encryption key (it wraps the private key).");
|
|
19318
19849
|
return config.secretKey;
|
|
19319
19850
|
}
|
|
19320
|
-
async function confirmInteractive$1(prompt) {
|
|
19321
|
-
const rl = createInterface({
|
|
19322
|
-
input: process.stdin,
|
|
19323
|
-
output: process.stderr
|
|
19324
|
-
});
|
|
19325
|
-
try {
|
|
19326
|
-
const a = (await new Promise((resolve) => rl.question(prompt, resolve))).trim().toLowerCase();
|
|
19327
|
-
return a === "y" || a === "yes";
|
|
19328
|
-
} finally {
|
|
19329
|
-
rl.close();
|
|
19330
|
-
}
|
|
19331
|
-
}
|
|
19332
19851
|
async function withRepo(databaseUrl, fn) {
|
|
19333
19852
|
const pool = createPool(resolveDatabaseUrl$3(databaseUrl));
|
|
19334
19853
|
const db = createDb$1(pool);
|
|
@@ -19435,7 +19954,7 @@ function registerDashboardEncryptionKeyCommands(program) {
|
|
|
19435
19954
|
process.stderr.write(`Rotated: new active dashboard-encryption key ${kid}.\n`);
|
|
19436
19955
|
}
|
|
19437
19956
|
}, {
|
|
19438
|
-
confirm:
|
|
19957
|
+
confirm: confirmPrompt,
|
|
19439
19958
|
yes: opts.yes,
|
|
19440
19959
|
dryRun: opts.dryRun
|
|
19441
19960
|
});
|
|
@@ -19459,7 +19978,7 @@ function renderTable$1(headers, rows) {
|
|
|
19459
19978
|
}
|
|
19460
19979
|
function registerEventLogCommands(program, getClient) {
|
|
19461
19980
|
const eventLog = program.command("event-log").description("Inspect the inbound webhook delivery log");
|
|
19462
|
-
eventLog.command("list").description("List inbound webhook deliveries (
|
|
19981
|
+
eventLog.command("list").description("List inbound webhook deliveries (admin API: /api/v1/admin/event-log)").option("--org <orgId>", "Filter by org/tenant ID").option("--routing-key <key>", "Filter by routing key (e.g. github:42)").option("--event <type>", "Filter by event type (e.g. push, pull_request)").option("--action <action>", "Filter by event action (e.g. opened, closed, synchronize for pull_request)").option("--status <s>", "Filter by outcome status (received|shed|processed|duplicate|lockfile_missing|lockfile_corrupt|failed)").option("--from <ts>", "ISO timestamp lower bound (inclusive)").option("--to <ts>", "ISO timestamp upper bound (exclusive)").option("--delivery-id <substr>", "Substring filter on delivery_id").option("--limit <n>", "Max results (default 50, max 200)", "50").option("--offset <n>", "Skip first N results", "0").option("--include-archived", "Merge cold-store archived rows into the result (requires --routing-key for cold scoping)").option("--json", "Emit raw JSON instead of a table").action(async (opts) => {
|
|
19463
19982
|
try {
|
|
19464
19983
|
const response = await getClient().listEventLog({
|
|
19465
19984
|
orgId: opts.org,
|
|
@@ -19573,7 +20092,7 @@ function renderTable(headers, rows) {
|
|
|
19573
20092
|
}
|
|
19574
20093
|
function registerAccessLogCommands(program, getClient) {
|
|
19575
20094
|
const accessLog = program.command("access-log").description("Inspect the read / admin-mutation access log");
|
|
19576
|
-
accessLog.command("list").description("List access-log rows (
|
|
20095
|
+
accessLog.command("list").description("List access-log rows (admin API: /api/v1/admin/access-log)").option("--org-id <orgId>", "Filter by org/tenant ID").option("--actor-type <t>", "Filter by actor type (user|api_key|service_account|platform_operator|system)").option("--actor-id <id>", "Filter by actor id (zsub, keyId, service_account id, ...)").option("--action <action>", "Filter by dotted action (e.g. run.detail.read, run.cancel)").option("--source <s>", "Filter by source (platform_proxy|admin_http|admin_cli)").option("--outcome <o>", "Filter by outcome (allowed|denied|error)").option("--target-type <t>", "Filter by target type (run|step|event_log|secret_scope|...)").option("--target-id <id>", "Filter by target id").option("--from <ts>", "ISO timestamp lower bound (inclusive)").option("--to <ts>", "ISO timestamp upper bound (exclusive)").option("--q <text>", "Filter by substring of error_message (trigram-indexed full-text search)").option("--agent-label <label>", "Filter by exact agent label").option("--agent-only", "Only agent-attributed rows").option("--limit <n>", "Max results (default 50, max 200)", "50").option("--cursor <c>", "Opaque cursor from a previous nextCursor").option("--json", "Emit raw JSON instead of a table").action(async (opts) => {
|
|
19577
20096
|
try {
|
|
19578
20097
|
const response = await getClient().listAccessLog({
|
|
19579
20098
|
orgId: opts.orgId,
|
|
@@ -19705,7 +20224,7 @@ function registerBackendCommands(program, getClient) {
|
|
|
19705
20224
|
try {
|
|
19706
20225
|
scopeInfo = ` has ${(await getClient().getBackend(name)).scopeCount ?? 0} discovered scopes -- they will become unavailable.`;
|
|
19707
20226
|
} catch {}
|
|
19708
|
-
if (!await
|
|
20227
|
+
if (!await confirmPrompt(`Backend "${name}"${scopeInfo} Continue? [y/N] `)) {
|
|
19709
20228
|
console.log("Aborted.");
|
|
19710
20229
|
return;
|
|
19711
20230
|
}
|
|
@@ -19892,21 +20411,6 @@ function printBackendSummary(b) {
|
|
|
19892
20411
|
console.log(` Sync interval: ${Math.round(Number(b.syncIntervalMs) / 6e4)}m`);
|
|
19893
20412
|
console.log(` Enabled: ${b.enabled}`);
|
|
19894
20413
|
}
|
|
19895
|
-
/**
|
|
19896
|
-
* Simple confirmation prompt.
|
|
19897
|
-
*/
|
|
19898
|
-
async function confirm$3(message) {
|
|
19899
|
-
const rl = createInterface({
|
|
19900
|
-
input: process.stdin,
|
|
19901
|
-
output: process.stderr
|
|
19902
|
-
});
|
|
19903
|
-
return new Promise((resolve) => {
|
|
19904
|
-
rl.question(message, (answer) => {
|
|
19905
|
-
rl.close();
|
|
19906
|
-
resolve(answer.toLowerCase() === "y" || answer.toLowerCase() === "yes");
|
|
19907
|
-
});
|
|
19908
|
-
});
|
|
19909
|
-
}
|
|
19910
20414
|
//#endregion
|
|
19911
20415
|
//#region src/cli/commands/diagnose.ts
|
|
19912
20416
|
/** ANSI color codes for terminal output. */
|
|
@@ -20257,6 +20761,16 @@ function registerInspectBundleCommand(program) {
|
|
|
20257
20761
|
//#endregion
|
|
20258
20762
|
//#region src/cli/service/entrypoint.ts
|
|
20259
20763
|
/**
|
|
20764
|
+
* Resolves the command a service unit should run.
|
|
20765
|
+
*
|
|
20766
|
+
* A `npm install -g kici-admin` install exposes only the `kici-admin` /
|
|
20767
|
+
* `kici-agent` CLI bins — the long-running orchestrator and agent servers are
|
|
20768
|
+
* separate module entry points (`dist/server.js` / `dist/standalone.js`) that
|
|
20769
|
+
* must be launched with `node <script>`. These helpers turn an install's
|
|
20770
|
+
* options into the `{ executablePath, args }` pair the platform service
|
|
20771
|
+
* managers write into the unit's run command.
|
|
20772
|
+
*/
|
|
20773
|
+
/**
|
|
20260
20774
|
* Pick the orchestrator server entry from an env file's `KICI_MODE`.
|
|
20261
20775
|
* `independent` runs the standalone server; `platform` / `hybrid` / unset run
|
|
20262
20776
|
* the Platform-connected server.
|
|
@@ -20281,6 +20795,34 @@ function resolveServiceExecutable(opts) {
|
|
|
20281
20795
|
args: [opts.entryScript]
|
|
20282
20796
|
};
|
|
20283
20797
|
}
|
|
20798
|
+
/**
|
|
20799
|
+
* The path of a server entry script installed beside this CLI, for a service
|
|
20800
|
+
* that runs `node <script>`. `resolve` is the calling module's
|
|
20801
|
+
* `import.meta.resolve`, or undefined where the CLI has none: a kici-admin from a
|
|
20802
|
+
* standalone package is a single CommonJS bundle that carries no orchestrator
|
|
20803
|
+
* or agent server. Either way the error names `--binary`, the launcher the
|
|
20804
|
+
* service runs instead.
|
|
20805
|
+
*/
|
|
20806
|
+
function resolveServerScript(specifier, component, resolve) {
|
|
20807
|
+
let cause = NO_RESOLVER;
|
|
20808
|
+
if (resolve) try {
|
|
20809
|
+
return fileURLToPath(resolve(specifier));
|
|
20810
|
+
} catch (err) {
|
|
20811
|
+
cause = err instanceof Error ? err.message : String(err);
|
|
20812
|
+
}
|
|
20813
|
+
throw new Error(missingServerMessage(component, specifier, cause));
|
|
20814
|
+
}
|
|
20815
|
+
/**
|
|
20816
|
+
* Refuse an install without `--binary` on a kici-admin that cannot resolve
|
|
20817
|
+
* installed packages, before the wizard runs or any file is written.
|
|
20818
|
+
*/
|
|
20819
|
+
function assertServerResolvable(component, binary, resolve) {
|
|
20820
|
+
if (binary === void 0 && resolve === void 0) throw new Error(missingServerMessage(component, `@kici-dev/${component}`, NO_RESOLVER));
|
|
20821
|
+
}
|
|
20822
|
+
const NO_RESOLVER = "this kici-admin cannot resolve installed packages";
|
|
20823
|
+
function missingServerMessage(component, specifier, cause) {
|
|
20824
|
+
return `kici-admin cannot find the ${component} server (${specifier}): ${cause}. Pass --binary with the path of the ${component} launcher, such as the one in a standalone ${component} package.`;
|
|
20825
|
+
}
|
|
20284
20826
|
//#endregion
|
|
20285
20827
|
//#region src/cli/service/deploy-env.ts
|
|
20286
20828
|
init_compose_path();
|
|
@@ -20359,6 +20901,11 @@ async function waitForQuiesce(poll, opts) {
|
|
|
20359
20901
|
}
|
|
20360
20902
|
}
|
|
20361
20903
|
/**
|
|
20904
|
+
* What a version switch prints when it cannot read the live migration ledger,
|
|
20905
|
+
* so it could not compare the schema to the recorded head.
|
|
20906
|
+
*/
|
|
20907
|
+
const SCHEMA_NOT_CHECKED = "The running service could not be reached, so the schema was not checked.";
|
|
20908
|
+
/**
|
|
20362
20909
|
* The last applied migration reported by a running service.
|
|
20363
20910
|
*
|
|
20364
20911
|
* Returns null when nothing has been applied — a fresh database — which the
|
|
@@ -20378,17 +20925,18 @@ function migrationsAfter(rows, head) {
|
|
|
20378
20925
|
* Decide whether the version pointer may move.
|
|
20379
20926
|
*
|
|
20380
20927
|
* Three outcomes, and the middle one is the compatibility shim: an instance
|
|
20381
|
-
* installed before `migrationHeads` existed
|
|
20382
|
-
*
|
|
20383
|
-
*
|
|
20384
|
-
*
|
|
20385
|
-
*
|
|
20928
|
+
* installed before `migrationHeads` existed, or one whose upgrade away from the
|
|
20929
|
+
* target ran without admin access, has no recorded head, and refusing there
|
|
20930
|
+
* would break the documented `--rollback` on exactly those installs. So an
|
|
20931
|
+
* unknown head warns loudly and falls through to the existing confirmation
|
|
20932
|
+
* prompt; the map is populated at the next version change with admin access,
|
|
20933
|
+
* arming the guard one upgrade later.
|
|
20386
20934
|
*/
|
|
20387
20935
|
function schemaGuardVerdict(input) {
|
|
20388
20936
|
const { targetVersion, recordedHead, liveRows } = input;
|
|
20389
20937
|
if (!recordedHead) return {
|
|
20390
20938
|
kind: "unknown-head",
|
|
20391
|
-
message: `WARNING: no migration head is recorded for ${targetVersion}.\n
|
|
20939
|
+
message: `WARNING: no migration head is recorded for ${targetVersion}.\n Either this instance was installed before the schema guard existed, or the\n upgrade away from ${targetVersion} ran without admin access (KICI_ADMIN_TOKEN).\n So it cannot be checked whether the database is ahead of that version. If it\n is, the older binary will refuse to boot with "corrupted migrations" and\n restart in a loop.\n Take a dump first ("kici-admin db backup"). If the switch does loop, start the\n older version with KICI_AUTO_MIGRATE=false — it boots only if every newer\n migration was purely additive.\n A version change with admin access records the head, which arms the guard\n for the next one.`
|
|
20392
20940
|
};
|
|
20393
20941
|
const live = appliedHead(liveRows);
|
|
20394
20942
|
if (live === null || live === recordedHead) return { kind: "ok" };
|
|
@@ -20475,8 +21023,7 @@ async function resolveUpgradeTarget(args) {
|
|
|
20475
21023
|
return {
|
|
20476
21024
|
config: {
|
|
20477
21025
|
name: resolved.manifest.name,
|
|
20478
|
-
|
|
20479
|
-
description: `KiCI ${component} service`,
|
|
21026
|
+
...SERVICE_TEXT[component],
|
|
20480
21027
|
executablePath: "",
|
|
20481
21028
|
envFilePath: resolved.manifest.envFilePath,
|
|
20482
21029
|
workingDirectory: resolved.manifest.configDir,
|
|
@@ -20491,18 +21038,12 @@ async function resolveUpgradeTarget(args) {
|
|
|
20491
21038
|
platform
|
|
20492
21039
|
};
|
|
20493
21040
|
}
|
|
20494
|
-
/** Prompt the user for confirmation
|
|
20495
|
-
|
|
20496
|
-
|
|
21041
|
+
/** Prompt the user for confirmation on stdout, beside the upgrade plan it follows. */
|
|
21042
|
+
function confirm$1(message) {
|
|
21043
|
+
return confirmPrompt(`${message} [y/N] `, {
|
|
20497
21044
|
input: process.stdin,
|
|
20498
21045
|
output: process.stdout
|
|
20499
21046
|
});
|
|
20500
|
-
return new Promise((resolve) => {
|
|
20501
|
-
rl.question(`${message} [y/N] `, (answer) => {
|
|
20502
|
-
rl.close();
|
|
20503
|
-
resolve(answer.trim().toLowerCase() === "y" || answer.trim().toLowerCase() === "yes");
|
|
20504
|
-
});
|
|
20505
|
-
});
|
|
20506
21047
|
}
|
|
20507
21048
|
/** Download a file from a URL to a local path. */
|
|
20508
21049
|
async function downloadArchive(url, destPath) {
|
|
@@ -20517,8 +21058,8 @@ async function downloadArchive(url, destPath) {
|
|
|
20517
21058
|
* Install base for a KiCI component instance.
|
|
20518
21059
|
*
|
|
20519
21060
|
* Name-scoped so that two instances of the same component (e.g. an org's
|
|
20520
|
-
*
|
|
20521
|
-
* versioned trees and symlinks. Per-platform bases:
|
|
21061
|
+
* production orchestrator and a test orchestrator on the same host) own
|
|
21062
|
+
* independent versioned trees and symlinks. Per-platform bases:
|
|
20522
21063
|
* - systemd / compose: /opt/kici/<name>/
|
|
20523
21064
|
* - launchd: /usr/local/kici/<name>/
|
|
20524
21065
|
* - windows: C:\Program Files\KiCI\<name>\
|
|
@@ -20570,10 +21111,73 @@ function checkPickFlagConflicts(opts) {
|
|
|
20570
21111
|
if (conflicts.length === 0) return null;
|
|
20571
21112
|
return `--pick cannot be combined with ${conflicts.join(", ")}. --pick activates an already-installed version; it never downloads or takes an explicit version.`;
|
|
20572
21113
|
}
|
|
21114
|
+
/**
|
|
21115
|
+
* Point the Windows service at `launcherPath`. sc.exe has no "update binary
|
|
21116
|
+
* path", so `install` replaces the registration: it checks the new launch
|
|
21117
|
+
* command first, then removes the old service and waits until it is gone
|
|
21118
|
+
* before it creates the new one. A launch command it refuses leaves the old
|
|
21119
|
+
* registration in place.
|
|
21120
|
+
*/
|
|
21121
|
+
async function reregisterWindowsService(manager, config, launcherPath) {
|
|
21122
|
+
config.executablePath = launcherPath;
|
|
21123
|
+
await manager.install(config);
|
|
21124
|
+
}
|
|
20573
21125
|
/** Check if the platform is Windows. */
|
|
20574
21126
|
function isWindows(platform) {
|
|
20575
21127
|
return platform === "windows";
|
|
20576
21128
|
}
|
|
21129
|
+
/**
|
|
21130
|
+
* Refuse to point a Windows service at a release from before KICI_ENV_FILE.
|
|
21131
|
+
* Every caller runs it before it stops the service, so a refused switch leaves
|
|
21132
|
+
* the service registered and running as it was.
|
|
21133
|
+
*/
|
|
21134
|
+
function assertWindowsLaunchReadsEnvFile(args) {
|
|
21135
|
+
if (launchReadsEnvFile(args.launch)) return;
|
|
21136
|
+
throw new Error(`refusing to ${args.action}: ${envFileRefusal(args.release, args.config.envFilePath)} The service was not changed.`);
|
|
21137
|
+
}
|
|
21138
|
+
/**
|
|
21139
|
+
* Stop the service, register it again on Windows, then start it: the restart
|
|
21140
|
+
* every npm-source upgrade ends with.
|
|
21141
|
+
*
|
|
21142
|
+
* `npm install -g` replaces the package in place, so the launch command the
|
|
21143
|
+
* service runs (`spec`, read back from its registration) stays valid. On
|
|
21144
|
+
* Windows the registration around that command (the service wrapper's
|
|
21145
|
+
* arguments, the description, the recovery settings) is whatever the CLI that
|
|
21146
|
+
* installed it wrote, so `install` writes the current one, as the archive
|
|
21147
|
+
* upgrade does. A launch command that names a file that does not exist was
|
|
21148
|
+
* read back wrong, so it is not registered: the service restarts as it was,
|
|
21149
|
+
* with a warning. systemd and launchd keep their unit as it is.
|
|
21150
|
+
*/
|
|
21151
|
+
async function restartOntoInstalledPackage(args) {
|
|
21152
|
+
const { manager, config, platform, spec } = args;
|
|
21153
|
+
const component = config.component ?? "orchestrator";
|
|
21154
|
+
const read = spec ? {
|
|
21155
|
+
executablePath: spec.execPath,
|
|
21156
|
+
args: spec.args
|
|
21157
|
+
} : null;
|
|
21158
|
+
const launch = read && fs.existsSync(read.executablePath) && fs.existsSync(launchedRelease(read)) ? read : null;
|
|
21159
|
+
if (isWindows(platform) && launch) assertWindowsLaunchReadsEnvFile({
|
|
21160
|
+
launch,
|
|
21161
|
+
release: launchedRelease(launch),
|
|
21162
|
+
action: `restart "${config.name}" onto the installed package`,
|
|
21163
|
+
config
|
|
21164
|
+
});
|
|
21165
|
+
if ((await manager.status(config)).state === "running") await manager.stop(config);
|
|
21166
|
+
if (isWindows(platform)) {
|
|
21167
|
+
if (launch) {
|
|
21168
|
+
try {
|
|
21169
|
+
await manager.install({
|
|
21170
|
+
...config,
|
|
21171
|
+
...launch
|
|
21172
|
+
});
|
|
21173
|
+
} catch (err) {
|
|
21174
|
+
throw new Error(`could not register the service again: ${toErrorMessage(err)}. Run \`kici-admin ${component} install\` for it.`, { cause: err });
|
|
21175
|
+
}
|
|
21176
|
+
console.log("Service registration rewritten from the current installer.");
|
|
21177
|
+
} else console.warn(`Warning: the service was not registered again, because its launch command could not be read, or names a file that does not exist. Run \`kici-admin ${component} install\` for it to apply the current registration.`);
|
|
21178
|
+
}
|
|
21179
|
+
await manager.start(config);
|
|
21180
|
+
}
|
|
20577
21181
|
/** Get the launcher script name for a component. */
|
|
20578
21182
|
function getLauncherName(component, platform) {
|
|
20579
21183
|
const baseName = component === "orchestrator" ? "kici-orchestrator-standalone" : "kici-agent";
|
|
@@ -20594,6 +21198,45 @@ function extractArchive(archivePath, destDir) {
|
|
|
20594
21198
|
return dirs[0];
|
|
20595
21199
|
}
|
|
20596
21200
|
/**
|
|
21201
|
+
* Extract a release archive into `tmpDir` and return the release folder in it.
|
|
21202
|
+
* On Windows a release from before KICI_ENV_FILE is refused here, before
|
|
21203
|
+
* anything is copied into the install base or the service is stopped.
|
|
21204
|
+
*/
|
|
21205
|
+
function extractRelease(args) {
|
|
21206
|
+
const { archivePath, tmpDir, component, platform, version, config } = args;
|
|
21207
|
+
const extractDir = path.join(tmpDir, "extract");
|
|
21208
|
+
const srcDir = path.join(extractDir, extractArchive(archivePath, extractDir));
|
|
21209
|
+
if (isWindows(platform)) assertWindowsLaunchReadsEnvFile({
|
|
21210
|
+
launch: { executablePath: path.join(srcDir, getLauncherName(component, platform)) },
|
|
21211
|
+
release: `version ${version}`,
|
|
21212
|
+
action: `upgrade "${config.name}" to ${version}`,
|
|
21213
|
+
config
|
|
21214
|
+
});
|
|
21215
|
+
return srcDir;
|
|
21216
|
+
}
|
|
21217
|
+
/**
|
|
21218
|
+
* Put the release in `archivePath` at `versionedDirPath`. The release is
|
|
21219
|
+
* extracted and checked first ({@link extractRelease}); only then is a folder
|
|
21220
|
+
* already at `versionedDirPath` removed, which the caller allows with
|
|
21221
|
+
* `--force`, and the release copied in. A refused release leaves the install
|
|
21222
|
+
* base as it was.
|
|
21223
|
+
*/
|
|
21224
|
+
function installRelease(args) {
|
|
21225
|
+
const { installBase, versionedDirPath, platform } = args;
|
|
21226
|
+
const srcDir = extractRelease(args);
|
|
21227
|
+
if (fs.existsSync(versionedDirPath)) {
|
|
21228
|
+
console.log(`Removing existing directory ${versionedDirPath} (--force)`);
|
|
21229
|
+
fs.rmSync(versionedDirPath, {
|
|
21230
|
+
recursive: true,
|
|
21231
|
+
force: true
|
|
21232
|
+
});
|
|
21233
|
+
}
|
|
21234
|
+
fs.mkdirSync(installBase, { recursive: true });
|
|
21235
|
+
if (isWindows(platform)) execSync(`xcopy "${srcDir}" "${versionedDirPath}" /E /I /Q /Y`, { stdio: "inherit" });
|
|
21236
|
+
else execSync(`cp -r "${srcDir}" "${versionedDirPath}"`, { stdio: "inherit" });
|
|
21237
|
+
console.log(`Extracted to ${versionedDirPath}`);
|
|
21238
|
+
}
|
|
21239
|
+
/**
|
|
20597
21240
|
* List installed versions for a component by scanning the install base directory
|
|
20598
21241
|
* for directories matching `{component}-{version}/`.
|
|
20599
21242
|
*/
|
|
@@ -20742,18 +21385,87 @@ function planNpmSourceUpgrade(opts) {
|
|
|
20742
21385
|
version: requestedVersion
|
|
20743
21386
|
};
|
|
20744
21387
|
}
|
|
21388
|
+
/**
|
|
21389
|
+
* The cmd.exe command line for npm on Windows. npm is a batch file there, which
|
|
21390
|
+
* Node.js starts only through a shell, and Node.js joins the parts of a shell
|
|
21391
|
+
* command with spaces and quotes none of them. The standard Node.js install is
|
|
21392
|
+
* under `C:\Program Files`, so the npm path is quoted here. An argument with a
|
|
21393
|
+
* character outside those of a package name, a version or a flag is refused
|
|
21394
|
+
* rather than handed to cmd.exe.
|
|
21395
|
+
*/
|
|
21396
|
+
function windowsShellCommand(cmd, args) {
|
|
21397
|
+
if (cmd.includes("\"")) throw new Error(`cannot run "${cmd}" through cmd.exe: the path holds a double quote`);
|
|
21398
|
+
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`);
|
|
21399
|
+
return [`"${cmd}"`, ...args].join(" ");
|
|
21400
|
+
}
|
|
20745
21401
|
const defaultSpawnRunner = (cmd, args, opts) => {
|
|
20746
|
-
const
|
|
21402
|
+
const env = opts?.env ?? process.env;
|
|
21403
|
+
const r = process.platform === "win32" ? spawnSync(windowsShellCommand(cmd, args), {
|
|
21404
|
+
encoding: "utf-8",
|
|
21405
|
+
shell: true,
|
|
21406
|
+
env
|
|
21407
|
+
}) : spawnSync(cmd, args, {
|
|
20747
21408
|
encoding: "utf-8",
|
|
20748
|
-
|
|
20749
|
-
env: opts?.env ?? process.env
|
|
21409
|
+
env
|
|
20750
21410
|
});
|
|
20751
21411
|
return {
|
|
20752
21412
|
status: r.status,
|
|
21413
|
+
stdout: r.stdout ?? "",
|
|
20753
21414
|
stderr: (r.stderr ?? "") + (r.error ? String(r.error) : "")
|
|
20754
21415
|
};
|
|
20755
21416
|
};
|
|
20756
21417
|
/**
|
|
21418
|
+
* The environment npm runs in: the unit's pinned node first on PATH, because
|
|
21419
|
+
* npm derives its global prefix from the node that runs it.
|
|
21420
|
+
*/
|
|
21421
|
+
function npmEnv(target) {
|
|
21422
|
+
const pinnedBinDir = path.dirname(target.nodeExecPath);
|
|
21423
|
+
return {
|
|
21424
|
+
...process.env,
|
|
21425
|
+
PATH: `${pinnedBinDir}${path.delimiter}${process.env.PATH ?? ""}`
|
|
21426
|
+
};
|
|
21427
|
+
}
|
|
21428
|
+
/**
|
|
21429
|
+
* Whether `version` of the KiCI packages reads KICI_ENV_FILE, asked of the
|
|
21430
|
+
* registry the unit's npm installs from. A release that reads it publishes the
|
|
21431
|
+
* env-file loader in `@kici-dev/shared`, which every KiCI package of the same
|
|
21432
|
+
* version depends on.
|
|
21433
|
+
*/
|
|
21434
|
+
function npmReleaseReadsEnvFile(target, version, run = defaultSpawnRunner) {
|
|
21435
|
+
const res = run(target.npmPath, [
|
|
21436
|
+
"view",
|
|
21437
|
+
`@kici-dev/shared@${version}`,
|
|
21438
|
+
"exports",
|
|
21439
|
+
"--json"
|
|
21440
|
+
], { env: npmEnv(target) });
|
|
21441
|
+
if (res.status !== 0) return {
|
|
21442
|
+
ok: false,
|
|
21443
|
+
error: res.stderr.trim() || `npm view exited with ${res.status}`
|
|
21444
|
+
};
|
|
21445
|
+
const out = res.stdout.trim();
|
|
21446
|
+
if (!out) return {
|
|
21447
|
+
ok: true,
|
|
21448
|
+
reads: false
|
|
21449
|
+
};
|
|
21450
|
+
let exportsMap;
|
|
21451
|
+
try {
|
|
21452
|
+
exportsMap = JSON.parse(out);
|
|
21453
|
+
} catch {
|
|
21454
|
+
return {
|
|
21455
|
+
ok: false,
|
|
21456
|
+
error: `npm view printed no JSON: ${out.slice(0, 200)}`
|
|
21457
|
+
};
|
|
21458
|
+
}
|
|
21459
|
+
if (Array.isArray(exportsMap)) return {
|
|
21460
|
+
ok: false,
|
|
21461
|
+
error: `npm matched more than one version of @kici-dev/shared for ${version}; pass an exact --version`
|
|
21462
|
+
};
|
|
21463
|
+
return {
|
|
21464
|
+
ok: true,
|
|
21465
|
+
reads: typeof exportsMap === "object" && exportsMap !== null && "./load-service-env-file" in exportsMap
|
|
21466
|
+
};
|
|
21467
|
+
}
|
|
21468
|
+
/**
|
|
20757
21469
|
* Install `<owningPackage>@<version>` globally under the unit's own pinned node,
|
|
20758
21470
|
* so it lands in exactly the global prefix the unit's ExecStart resolves from.
|
|
20759
21471
|
*
|
|
@@ -20768,16 +21480,11 @@ const defaultSpawnRunner = (cmd, args, opts) => {
|
|
|
20768
21480
|
* `run` seam is injected in tests.
|
|
20769
21481
|
*/
|
|
20770
21482
|
function installGlobalPackage(target, version, run = defaultSpawnRunner) {
|
|
20771
|
-
const pinnedBinDir = path.dirname(target.nodeExecPath);
|
|
20772
|
-
const env = {
|
|
20773
|
-
...process.env,
|
|
20774
|
-
PATH: `${pinnedBinDir}${path.delimiter}${process.env.PATH ?? ""}`
|
|
20775
|
-
};
|
|
20776
21483
|
const res = run(target.npmPath, [
|
|
20777
21484
|
"install",
|
|
20778
21485
|
"-g",
|
|
20779
21486
|
`${target.owningPackage}@${version}`
|
|
20780
|
-
], { env });
|
|
21487
|
+
], { env: npmEnv(target) });
|
|
20781
21488
|
return {
|
|
20782
21489
|
ok: res.status === 0,
|
|
20783
21490
|
stderr: res.stderr
|
|
@@ -20886,18 +21593,10 @@ async function performVersionedUpgrade(component, opts, hooks = {}) {
|
|
|
20886
21593
|
const version = opts.version;
|
|
20887
21594
|
const versionedDirName = `${component}-${version}`;
|
|
20888
21595
|
const versionedDirPath = path.join(installBase, versionedDirName);
|
|
20889
|
-
if (fs.existsSync(versionedDirPath)) {
|
|
20890
|
-
|
|
20891
|
-
|
|
20892
|
-
|
|
20893
|
-
recursive: true,
|
|
20894
|
-
force: true
|
|
20895
|
-
});
|
|
20896
|
-
} else {
|
|
20897
|
-
console.error(`Error: version directory already exists: ${versionedDirPath}`);
|
|
20898
|
-
console.error("Use --force to overwrite.");
|
|
20899
|
-
process.exit(1);
|
|
20900
|
-
}
|
|
21596
|
+
if (fs.existsSync(versionedDirPath) && !opts.force) {
|
|
21597
|
+
console.error(`Error: version directory already exists: ${versionedDirPath}`);
|
|
21598
|
+
console.error("Use --force to overwrite.");
|
|
21599
|
+
process.exit(1);
|
|
20901
21600
|
}
|
|
20902
21601
|
if (!await manager.isInstalled(config)) {
|
|
20903
21602
|
console.error(`Error: service "${config.name}" is not installed`);
|
|
@@ -20924,19 +21623,27 @@ async function performVersionedUpgrade(component, opts, hooks = {}) {
|
|
|
20924
21623
|
console.log(` Install path: ${versionedDirPath}`);
|
|
20925
21624
|
console.log(" The service will be stopped during upgrade.");
|
|
20926
21625
|
console.log("");
|
|
20927
|
-
if (!await confirm$
|
|
21626
|
+
if (!await confirm$1("Proceed with upgrade?")) {
|
|
20928
21627
|
console.log("Upgrade cancelled.");
|
|
20929
21628
|
return;
|
|
20930
21629
|
}
|
|
20931
21630
|
}
|
|
20932
21631
|
console.log("Extracting archive...");
|
|
20933
|
-
|
|
20934
|
-
|
|
20935
|
-
|
|
20936
|
-
|
|
20937
|
-
|
|
20938
|
-
|
|
20939
|
-
|
|
21632
|
+
try {
|
|
21633
|
+
installRelease({
|
|
21634
|
+
archivePath,
|
|
21635
|
+
tmpDir,
|
|
21636
|
+
installBase,
|
|
21637
|
+
versionedDirPath,
|
|
21638
|
+
component,
|
|
21639
|
+
platform,
|
|
21640
|
+
version,
|
|
21641
|
+
config
|
|
21642
|
+
});
|
|
21643
|
+
} catch (err) {
|
|
21644
|
+
await cleanupTmpDir();
|
|
21645
|
+
throw err;
|
|
21646
|
+
}
|
|
20940
21647
|
const preStopHeads = await runPreStopSafety({
|
|
20941
21648
|
component,
|
|
20942
21649
|
opts,
|
|
@@ -20950,10 +21657,7 @@ async function performVersionedUpgrade(component, opts, hooks = {}) {
|
|
|
20950
21657
|
}
|
|
20951
21658
|
if (isWindows(platform)) {
|
|
20952
21659
|
const launcherPath = path.join(versionedDirPath, getLauncherName(component, platform));
|
|
20953
|
-
config
|
|
20954
|
-
await manager.uninstall(config);
|
|
20955
|
-
await new Promise((r) => setTimeout(r, 2e3));
|
|
20956
|
-
await manager.install(config);
|
|
21660
|
+
await reregisterWindowsService(manager, config, launcherPath);
|
|
20957
21661
|
writeCurrentVersion(installBase, component, version);
|
|
20958
21662
|
console.log(`Service registration updated to ${launcherPath}`);
|
|
20959
21663
|
} else {
|
|
@@ -21018,17 +21722,36 @@ async function performNpmSourceUpgrade(component, config, resolvedInstance, mana
|
|
|
21018
21722
|
process.exit(1);
|
|
21019
21723
|
}
|
|
21020
21724
|
if (action.kind === "restart-only") {
|
|
21021
|
-
await restartOnlyUpgrade(
|
|
21725
|
+
await restartOnlyUpgrade({
|
|
21726
|
+
component,
|
|
21727
|
+
config,
|
|
21728
|
+
resolvedInstance,
|
|
21729
|
+
manager,
|
|
21730
|
+
verdict: action.verdict,
|
|
21731
|
+
opts,
|
|
21732
|
+
platform,
|
|
21733
|
+
spec
|
|
21734
|
+
});
|
|
21022
21735
|
return;
|
|
21023
21736
|
}
|
|
21024
|
-
await performSelfDrivingInstall(
|
|
21737
|
+
await performSelfDrivingInstall({
|
|
21738
|
+
component,
|
|
21739
|
+
config,
|
|
21740
|
+
resolvedInstance,
|
|
21741
|
+
manager,
|
|
21742
|
+
action,
|
|
21743
|
+
opts,
|
|
21744
|
+
platform,
|
|
21745
|
+
spec
|
|
21746
|
+
});
|
|
21025
21747
|
}
|
|
21026
21748
|
/**
|
|
21027
21749
|
* Restart-only path: the operator pre-installed `@kici-dev/<pkg>@<version>` (or
|
|
21028
21750
|
* kici-admin) themselves. Verify the unit will launch the invoking CLI's version,
|
|
21029
21751
|
* restart onto it, and persist the manifest version.
|
|
21030
21752
|
*/
|
|
21031
|
-
async function restartOnlyUpgrade(
|
|
21753
|
+
async function restartOnlyUpgrade(args) {
|
|
21754
|
+
const { config, resolvedInstance, manager, verdict, opts, platform, spec } = args;
|
|
21032
21755
|
if (!verdict.ok) {
|
|
21033
21756
|
console.error(`Error: ${verdict.reason}`);
|
|
21034
21757
|
process.exit(1);
|
|
@@ -21037,14 +21760,18 @@ async function restartOnlyUpgrade(component, config, resolvedInstance, manager,
|
|
|
21037
21760
|
console.log(`This will restart "${config.name}" onto the npm-installed version ${verdict.version}.`);
|
|
21038
21761
|
console.log(" The service will be stopped briefly during the restart.");
|
|
21039
21762
|
console.log("");
|
|
21040
|
-
if (!await confirm$
|
|
21763
|
+
if (!await confirm$1("Proceed with upgrade?")) {
|
|
21041
21764
|
console.log("Upgrade cancelled.");
|
|
21042
21765
|
return;
|
|
21043
21766
|
}
|
|
21044
21767
|
}
|
|
21045
21768
|
console.log("Restarting service onto the npm-installed package...");
|
|
21046
|
-
|
|
21047
|
-
|
|
21769
|
+
await restartOntoInstalledPackage({
|
|
21770
|
+
manager,
|
|
21771
|
+
config,
|
|
21772
|
+
platform,
|
|
21773
|
+
spec
|
|
21774
|
+
});
|
|
21048
21775
|
if (verdict.manifestVersion !== null) writeManifest(resolvedInstance.instanceDir, {
|
|
21049
21776
|
...resolvedInstance.manifest,
|
|
21050
21777
|
kiciVersion: verdict.manifestVersion
|
|
@@ -21057,19 +21784,26 @@ async function restartOnlyUpgrade(component, config, resolvedInstance, manager,
|
|
|
21057
21784
|
* pinned node/npm, restart, and verify the unit now launches the requested
|
|
21058
21785
|
* version.
|
|
21059
21786
|
*/
|
|
21060
|
-
async function performSelfDrivingInstall(
|
|
21787
|
+
async function performSelfDrivingInstall(args) {
|
|
21788
|
+
const { component, config, resolvedInstance, manager, action, opts, platform, spec } = args;
|
|
21061
21789
|
const { target, version } = action;
|
|
21790
|
+
if (isWindows(platform)) {
|
|
21791
|
+
const release = `${target.owningPackage}@${version}`;
|
|
21792
|
+
const verdict = npmReleaseReadsEnvFile(target, version, args.run);
|
|
21793
|
+
if (!verdict.ok) throw new Error(`could not check whether ${release} reads KICI_ENV_FILE: ${verdict.error}. The service and the installed package were not changed.`);
|
|
21794
|
+
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.`);
|
|
21795
|
+
}
|
|
21062
21796
|
if (!opts.yes) {
|
|
21063
21797
|
console.log(`This will install ${target.owningPackage}@${version} using ${target.npmPath}`);
|
|
21064
21798
|
console.log(` (node runtime: ${target.nodeExecPath}), then restart "${config.name}".`);
|
|
21065
21799
|
console.log("");
|
|
21066
|
-
if (!await confirm$
|
|
21800
|
+
if (!await confirm$1("Proceed with upgrade?")) {
|
|
21067
21801
|
console.log("Upgrade cancelled.");
|
|
21068
21802
|
return;
|
|
21069
21803
|
}
|
|
21070
21804
|
}
|
|
21071
21805
|
console.log(`Installing ${target.owningPackage}@${version} under the unit's runtime...`);
|
|
21072
|
-
const result = installGlobalPackage(target, version);
|
|
21806
|
+
const result = installGlobalPackage(target, version, args.run);
|
|
21073
21807
|
if (!result.ok) {
|
|
21074
21808
|
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." : "";
|
|
21075
21809
|
console.error(`Error: npm install failed for ${target.owningPackage}@${version}.${hint}`);
|
|
@@ -21077,8 +21811,12 @@ async function performSelfDrivingInstall(component, config, resolvedInstance, ma
|
|
|
21077
21811
|
process.exit(1);
|
|
21078
21812
|
}
|
|
21079
21813
|
console.log("Restarting service onto the freshly-installed package...");
|
|
21080
|
-
|
|
21081
|
-
|
|
21814
|
+
await restartOntoInstalledPackage({
|
|
21815
|
+
manager,
|
|
21816
|
+
config,
|
|
21817
|
+
platform,
|
|
21818
|
+
spec
|
|
21819
|
+
});
|
|
21082
21820
|
const newSpec = await manager.readLaunchSpec(config);
|
|
21083
21821
|
const launched = newSpec ? resolveVersionFromLaunchSpec(newSpec, component) : null;
|
|
21084
21822
|
if (launched !== version) {
|
|
@@ -21120,7 +21858,7 @@ async function handlePick(component, platform, installBase, config, manager, res
|
|
|
21120
21858
|
console.log(` Install path: ${path.join(installBase, `${component}-${target}`)}`);
|
|
21121
21859
|
console.log(" The service will be stopped during the switch.");
|
|
21122
21860
|
console.log("");
|
|
21123
|
-
if (!await confirm$
|
|
21861
|
+
if (!await confirm$1("Proceed with switch?")) {
|
|
21124
21862
|
console.log("Pick cancelled.");
|
|
21125
21863
|
return;
|
|
21126
21864
|
}
|
|
@@ -21141,14 +21879,6 @@ async function handlePick(component, platform, installBase, config, manager, res
|
|
|
21141
21879
|
console.log(`Switched: ${current ?? "(unknown)"} -> ${target}`);
|
|
21142
21880
|
}
|
|
21143
21881
|
/**
|
|
21144
|
-
* Stop the service, switch the active version pointer to an already-installed
|
|
21145
|
-
* versioned directory (atomic symlink on Unix; uninstall→install + version file
|
|
21146
|
-
* on Windows), restart, and persist the new `kiciVersion` into the manifest.
|
|
21147
|
-
*
|
|
21148
|
-
* Shared by `--rollback` (switch to the previous version) and `--pick` (switch
|
|
21149
|
-
* to any chosen installed version).
|
|
21150
|
-
*/
|
|
21151
|
-
/**
|
|
21152
21882
|
* Refuse a version switch whose target cannot boot against the current schema.
|
|
21153
21883
|
*
|
|
21154
21884
|
* `--rollback` and `--pick` both flip a version pointer, and the previous
|
|
@@ -21166,9 +21896,9 @@ async function assertSchemaAllowsSwitch(args) {
|
|
|
21166
21896
|
const rows = await readLiveMigrationRows(hooks.migrationStatus);
|
|
21167
21897
|
if (rows === null) {
|
|
21168
21898
|
console.log("");
|
|
21169
|
-
console.log(
|
|
21899
|
+
console.log(` !! ${SCHEMA_NOT_CHECKED}`);
|
|
21170
21900
|
console.log("");
|
|
21171
|
-
return yes ? true : confirm$
|
|
21901
|
+
return yes ? true : confirm$1("Proceed with the switch anyway?");
|
|
21172
21902
|
}
|
|
21173
21903
|
const recordedHead = resolvedInstance.manifest.migrationHeads?.[targetVersion];
|
|
21174
21904
|
const verdict = schemaGuardVerdict({
|
|
@@ -21181,7 +21911,7 @@ async function assertSchemaAllowsSwitch(args) {
|
|
|
21181
21911
|
console.log("");
|
|
21182
21912
|
console.log(verdict.message);
|
|
21183
21913
|
console.log("");
|
|
21184
|
-
return yes ? true : confirm$
|
|
21914
|
+
return yes ? true : confirm$1("Proceed with the switch anyway?");
|
|
21185
21915
|
}
|
|
21186
21916
|
if (!migrateDown) {
|
|
21187
21917
|
console.error("");
|
|
@@ -21208,8 +21938,23 @@ async function assertSchemaAllowsSwitch(args) {
|
|
|
21208
21938
|
}
|
|
21209
21939
|
return true;
|
|
21210
21940
|
}
|
|
21941
|
+
/**
|
|
21942
|
+
* Stop the service, switch the active version pointer to an already-installed
|
|
21943
|
+
* versioned directory (atomic symlink on Unix; re-registration + version file
|
|
21944
|
+
* on Windows), restart, and persist the new `kiciVersion` into the manifest.
|
|
21945
|
+
*
|
|
21946
|
+
* Shared by `--rollback` (switch to the previous version) and `--pick` (switch
|
|
21947
|
+
* to any chosen installed version).
|
|
21948
|
+
*/
|
|
21211
21949
|
async function switchToInstalledVersion(args) {
|
|
21212
21950
|
const { component, platform, installBase, config, manager, resolvedInstance, targetVersion } = args;
|
|
21951
|
+
const launcherPath = path.join(installBase, `${component}-${targetVersion}`, getLauncherName(component, platform));
|
|
21952
|
+
if (isWindows(platform)) assertWindowsLaunchReadsEnvFile({
|
|
21953
|
+
launch: { executablePath: launcherPath },
|
|
21954
|
+
release: `version ${targetVersion}`,
|
|
21955
|
+
action: `switch "${config.name}" to ${targetVersion}`,
|
|
21956
|
+
config
|
|
21957
|
+
});
|
|
21213
21958
|
if (!await assertSchemaAllowsSwitch({
|
|
21214
21959
|
component,
|
|
21215
21960
|
targetVersion,
|
|
@@ -21227,10 +21972,7 @@ async function switchToInstalledVersion(args) {
|
|
|
21227
21972
|
console.log("Service stopped.");
|
|
21228
21973
|
}
|
|
21229
21974
|
if (isWindows(platform)) {
|
|
21230
|
-
|
|
21231
|
-
config.executablePath = launcherPath;
|
|
21232
|
-
await manager.uninstall(config);
|
|
21233
|
-
await manager.install(config);
|
|
21975
|
+
await reregisterWindowsService(manager, config, launcherPath);
|
|
21234
21976
|
writeCurrentVersion(installBase, component, targetVersion);
|
|
21235
21977
|
console.log(`Service registration updated to ${launcherPath}`);
|
|
21236
21978
|
} else {
|
|
@@ -21276,7 +22018,7 @@ async function handleRollback(component, platform, installBase, config, manager,
|
|
|
21276
22018
|
console.log(` Current version: ${currentVersion}`);
|
|
21277
22019
|
console.log(` Rollback to: ${previousVersion}`);
|
|
21278
22020
|
console.log("");
|
|
21279
|
-
if (!await confirm$
|
|
22021
|
+
if (!await confirm$1("Proceed with rollback?")) {
|
|
21280
22022
|
console.log("Rollback cancelled.");
|
|
21281
22023
|
return;
|
|
21282
22024
|
}
|
|
@@ -21893,6 +22635,8 @@ Token vocabulary:
|
|
|
21893
22635
|
\`kici-admin join\` to add another orchestrator as a peer to an existing cluster.
|
|
21894
22636
|
`).option("--platform <type>", "Force the service platform (systemd, launchd, windows, compose). Default: detected from the host").option("--mode <mode>", `Operating mode written to the env file (${OrchestratorMode.options.join(", ")}). Naming it with --env-file overwrites the copied file's own KICI_MODE; omitting it leaves that value alone`, DEFAULT_INSTALL_MODE).option("--env-file <path>", "Path to existing env/config file to use").option("--binary <path>", "Path to orchestrator binary (default: current executable)").option("--dev", "Dev mode: spin up PostgreSQL container on port 15432").option("--wizard", "Run the interactive setup wizard (default on an interactive terminal)").option("--no-wizard", "Skip the wizard and write a stub env file to edit by hand").option("--name <name>", "Service name", "kici-orchestrator").option("--system", "Install as system-level service (requires root)").option("--user-level", "Install as user-level service (no root required)").option("--user <name>", "Run the service as the named user (system-level launchd only; sets UserName in plist so the daemon drops privileges)").option("--instance-dir <path>", "Deploy folder; the instance manifest is written here (default: current working directory)").option("--force", "Overwrite an existing same-named foreign instance").action(async (opts, command) => {
|
|
21895
22637
|
try {
|
|
22638
|
+
const resolveModule = typeof import.meta.resolve === "function" ? (specifier) => import.meta.resolve(specifier) : void 0;
|
|
22639
|
+
assertServerResolvable("orchestrator", opts.binary, resolveModule);
|
|
21896
22640
|
if (opts.wizard === true && opts.envFile) {
|
|
21897
22641
|
console.error("Error: Cannot use --wizard with --env-file");
|
|
21898
22642
|
process.exit(1);
|
|
@@ -21925,7 +22669,7 @@ Token vocabulary:
|
|
|
21925
22669
|
})).find((c) => c.name === serviceName);
|
|
21926
22670
|
if (existing && existing.instanceDir !== instanceDir && !opts.force) {
|
|
21927
22671
|
const at = existing.instanceDir ?? "(no manifest)";
|
|
21928
|
-
console.error(`Error: an orchestrator instance "${serviceName}" is already installed at ${at}.\n\nUpgrading this service? Don't re-run install — installing again is for first-time\nsetup, not upgrades. For an npm-global install run
|
|
22672
|
+
console.error(`Error: an orchestrator instance "${serviceName}" is already installed at ${at}.\n\nUpgrading this service? Don't re-run install — installing again is for first-time\nsetup, not upgrades. For an npm-global install run\n\`kici-admin orchestrator upgrade --version <version>\`; for a versioned-directory install\nrun \`kici-admin orchestrator upgrade --from <archive> --version <version>\`.\n\nInstalling a second, separate instance? Pass a different --name or --instance-dir,\nor --force to take over this one.`);
|
|
21929
22673
|
process.exit(1);
|
|
21930
22674
|
}
|
|
21931
22675
|
const configDir = getConfigDir(serviceName, userLevel);
|
|
@@ -21933,6 +22677,7 @@ Token vocabulary:
|
|
|
21933
22677
|
fs.mkdirSync(configDir, { recursive: true });
|
|
21934
22678
|
fs.mkdirSync(logDir, { recursive: true });
|
|
21935
22679
|
const envFilePath = path.join(configDir, `${serviceName}.env`);
|
|
22680
|
+
if (platform === "windows" && !userLevel) restrictEnvFileAccess(envFilePath);
|
|
21936
22681
|
let devDbUrl;
|
|
21937
22682
|
if (opts.dev) {
|
|
21938
22683
|
devDbUrl = startDevPostgres(`${serviceName}-dev-pg`);
|
|
@@ -21966,7 +22711,7 @@ Token vocabulary:
|
|
|
21966
22711
|
envFilePath
|
|
21967
22712
|
});
|
|
21968
22713
|
fs.writeFileSync(envFilePath, upsertDeployEnvLines(fs.readFileSync(envFilePath, "utf-8"), deployLines), "utf-8");
|
|
21969
|
-
const entryScript = opts.binary ? void 0 :
|
|
22714
|
+
const entryScript = opts.binary ? void 0 : resolveServerScript(`@kici-dev/orchestrator/${selectServerEntry(fs.readFileSync(envFilePath, "utf-8"))}`, "orchestrator", resolveModule);
|
|
21970
22715
|
const { executablePath, args } = resolveServiceExecutable({
|
|
21971
22716
|
binary: opts.binary ? path.resolve(opts.binary) : void 0,
|
|
21972
22717
|
nodePath: process.execPath,
|
|
@@ -21982,8 +22727,7 @@ Token vocabulary:
|
|
|
21982
22727
|
} catch {}
|
|
21983
22728
|
const config = {
|
|
21984
22729
|
name: serviceName,
|
|
21985
|
-
|
|
21986
|
-
description: "KiCI CI/CD workflow orchestrator service",
|
|
22730
|
+
...SERVICE_TEXT.orchestrator,
|
|
21987
22731
|
executablePath,
|
|
21988
22732
|
args,
|
|
21989
22733
|
nodeBinDir: path.dirname(process.execPath),
|
|
@@ -21996,7 +22740,7 @@ Token vocabulary:
|
|
|
21996
22740
|
restartPolicy: DEFAULT_RESTART_POLICY
|
|
21997
22741
|
};
|
|
21998
22742
|
await manager.install(config);
|
|
21999
|
-
const manifestFile =
|
|
22743
|
+
const { file: manifestFile, warning: manifestWarning } = writeInstallManifest(instanceDir, {
|
|
22000
22744
|
component: "orchestrator",
|
|
22001
22745
|
name: serviceName,
|
|
22002
22746
|
platform,
|
|
@@ -22007,7 +22751,11 @@ Token vocabulary:
|
|
|
22007
22751
|
installBase: getInstallBase(platform, serviceName),
|
|
22008
22752
|
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
22009
22753
|
kiciVersion: readKiciVersion()
|
|
22754
|
+
}, {
|
|
22755
|
+
execPath: executablePath,
|
|
22756
|
+
args
|
|
22010
22757
|
});
|
|
22758
|
+
if (manifestWarning) console.warn(`Warning: ${manifestWarning}`);
|
|
22011
22759
|
try {
|
|
22012
22760
|
appendIndexEntry(kiciRoot, {
|
|
22013
22761
|
component: "orchestrator",
|
|
@@ -22224,23 +22972,135 @@ function registerOrchestratorRestart(orchestrator) {
|
|
|
22224
22972
|
});
|
|
22225
22973
|
}
|
|
22226
22974
|
//#endregion
|
|
22227
|
-
//#region src/cli/commands/
|
|
22228
|
-
|
|
22229
|
-
|
|
22230
|
-
|
|
22231
|
-
|
|
22975
|
+
//#region src/cli/commands/service-health.ts
|
|
22976
|
+
/**
|
|
22977
|
+
* Reads a locally installed KiCI service's health endpoints and formats what
|
|
22978
|
+
* they report. `kici-admin orchestrator status` and `kici-admin agent status`
|
|
22979
|
+
* both render through here, so the two sections agree on layout and on how a
|
|
22980
|
+
* missing field is handled.
|
|
22981
|
+
*/
|
|
22982
|
+
/** How long a health request waits unless the caller passes its own timeout. */
|
|
22983
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 3e3;
|
|
22984
|
+
/** Hex digits of a bundle hash to show: the length the build prints in its `deps:` line. */
|
|
22985
|
+
const SHORT_HASH_LENGTH = 12;
|
|
22986
|
+
/** Bind addresses that mean "every interface", reached on `localhost`. */
|
|
22987
|
+
const WILDCARD_HOSTS = /* @__PURE__ */ new Set([
|
|
22988
|
+
"",
|
|
22989
|
+
"0.0.0.0",
|
|
22990
|
+
"::",
|
|
22991
|
+
"[::]"
|
|
22992
|
+
]);
|
|
22993
|
+
/**
|
|
22994
|
+
* Read the env file, or empty content when it is missing or unreadable. A file
|
|
22995
|
+
* this account may not read is reported: the port and address the caller then
|
|
22996
|
+
* falls back to may not be the ones the service uses. On Windows only
|
|
22997
|
+
* LocalSystem and Administrators can read a service's env file.
|
|
22998
|
+
*/
|
|
22999
|
+
function readEnvContent(envFilePath, warn = console.warn) {
|
|
22232
23000
|
try {
|
|
22233
|
-
return fs.
|
|
22234
|
-
} catch {
|
|
23001
|
+
return fs.readFileSync(envFilePath, "utf-8");
|
|
23002
|
+
} catch (err) {
|
|
23003
|
+
const code = err.code;
|
|
23004
|
+
if (code === "EACCES" || code === "EPERM") warn(`Warning: cannot read ${envFilePath} (${code}), so the default port and address are used. Run the command as an administrator, or as an account that can read the file.`);
|
|
22235
23005
|
return "";
|
|
22236
23006
|
}
|
|
22237
23007
|
}
|
|
22238
|
-
/**
|
|
22239
|
-
|
|
22240
|
-
|
|
22241
|
-
|
|
22242
|
-
|
|
23008
|
+
/**
|
|
23009
|
+
* Where the service answers, read from its env file: `KICI_PORT` (or
|
|
23010
|
+
* `defaultPort`), `KICI_HOST` and `KICI_BASE_PATH`. A service bound to every
|
|
23011
|
+
* interface is reached on `localhost`; one bound to a single address is reached
|
|
23012
|
+
* there. The agent reads none of the last two, so its env file never sets them.
|
|
23013
|
+
*/
|
|
23014
|
+
function readLocalEndpoint(envContent, defaultPort) {
|
|
23015
|
+
const portValue = readEnvValue(envContent, "KICI_PORT");
|
|
23016
|
+
const port = portValue === void 0 ? NaN : parseInt(portValue, 10);
|
|
23017
|
+
const bindHost = readEnvValue(envContent, "KICI_HOST")?.trim() ?? "";
|
|
23018
|
+
const trimmedPath = (readEnvValue(envContent, "KICI_BASE_PATH")?.trim() ?? "").replace(/^\/+|\/+$/g, "");
|
|
23019
|
+
return {
|
|
23020
|
+
host: WILDCARD_HOSTS.has(bindHost) ? "localhost" : bindHost,
|
|
23021
|
+
port: isNaN(port) ? defaultPort : port,
|
|
23022
|
+
pathPrefix: trimmedPath === "" ? "" : `/${trimmedPath}`
|
|
23023
|
+
};
|
|
23024
|
+
}
|
|
23025
|
+
/** The URL of `pathname` on `endpoint`, with an IPv6 host bracketed. */
|
|
23026
|
+
function localUrl(endpoint, pathname) {
|
|
23027
|
+
return `http://${endpoint.host.includes(":") && !endpoint.host.startsWith("[") ? `[${endpoint.host}]` : endpoint.host}:${endpoint.port}${endpoint.pathPrefix}${pathname}`;
|
|
22243
23028
|
}
|
|
23029
|
+
/**
|
|
23030
|
+
* GET a JSON body from a service on this host. `acceptStatuses` names the
|
|
23031
|
+
* non-2xx codes whose body is still an answer: `/ready` returns its checks
|
|
23032
|
+
* with a 503 when one fails. Returns null when nothing answers in time.
|
|
23033
|
+
*/
|
|
23034
|
+
async function fetchLocalJson(endpoint, pathname, opts = {}) {
|
|
23035
|
+
const controller = new AbortController();
|
|
23036
|
+
const timeout = setTimeout(() => controller.abort(), opts.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS);
|
|
23037
|
+
try {
|
|
23038
|
+
const res = await fetch(localUrl(endpoint, pathname), { signal: controller.signal });
|
|
23039
|
+
if (!res.ok && !(opts.acceptStatuses ?? []).includes(res.status)) return null;
|
|
23040
|
+
return await res.json();
|
|
23041
|
+
} catch {
|
|
23042
|
+
return null;
|
|
23043
|
+
} finally {
|
|
23044
|
+
clearTimeout(timeout);
|
|
23045
|
+
}
|
|
23046
|
+
}
|
|
23047
|
+
/**
|
|
23048
|
+
* A `/health` body with its deprecated `buildCommit` replaced by its version.
|
|
23049
|
+
* A service older than this CLI reports a commit ID from KiCI's private
|
|
23050
|
+
* repository there, which a reader cannot resolve; `--json` keeps the key, a
|
|
23051
|
+
* string as before, and carries the release version in it. A body that reports
|
|
23052
|
+
* no `buildCommit` comes back unchanged.
|
|
23053
|
+
*/
|
|
23054
|
+
function hideBuildCommit(health) {
|
|
23055
|
+
if (!("buildCommit" in health)) return health;
|
|
23056
|
+
return {
|
|
23057
|
+
...health,
|
|
23058
|
+
buildCommit: health.version ?? "unknown"
|
|
23059
|
+
};
|
|
23060
|
+
}
|
|
23061
|
+
/**
|
|
23062
|
+
* Rows for the build fingerprint and process uptime of a `/health` body. A
|
|
23063
|
+
* field the body lacks is skipped, so a service older than this CLI renders
|
|
23064
|
+
* the fields it does report. The version line never shows `buildCommit`
|
|
23065
|
+
* (see {@link hideBuildCommit}).
|
|
23066
|
+
*/
|
|
23067
|
+
function buildInfoRows(health) {
|
|
23068
|
+
const rows = [];
|
|
23069
|
+
if (health.version) rows.push(["Version", health.buildDate ? `${health.version} (built ${health.buildDate})` : health.version]);
|
|
23070
|
+
if (health.sdkVersion) rows.push(["SDK", health.sdkBundleHash ? `${health.sdkVersion} (bundle ${health.sdkBundleHash.slice(0, SHORT_HASH_LENGTH)})` : health.sdkVersion]);
|
|
23071
|
+
if (typeof health.uptime === "number") rows.push(["Uptime", formatUptime(Math.floor(health.uptime))]);
|
|
23072
|
+
return rows;
|
|
23073
|
+
}
|
|
23074
|
+
/**
|
|
23075
|
+
* A status section: a blank separator line, the heading, then one line per
|
|
23076
|
+
* row with each value starting at `valueColumn`. A body with no field this CLI
|
|
23077
|
+
* recognises still gets a line saying so, so the heading never stands alone.
|
|
23078
|
+
*/
|
|
23079
|
+
function formatHealthSection(heading, rows, valueColumn) {
|
|
23080
|
+
return [
|
|
23081
|
+
"",
|
|
23082
|
+
heading,
|
|
23083
|
+
...(rows.length > 0 ? rows : [["Health", "answered, with no fields this CLI recognises"]]).map(([label, value]) => `${label}:`.padEnd(valueColumn) + value)
|
|
23084
|
+
];
|
|
23085
|
+
}
|
|
23086
|
+
//#endregion
|
|
23087
|
+
//#region src/cli/commands/orchestrator-service/status.ts
|
|
23088
|
+
init_service();
|
|
23089
|
+
init_compose_path();
|
|
23090
|
+
/** Time `/ready` gets beyond the database-pool acquire timeout, for its query and its response. */
|
|
23091
|
+
const READINESS_MARGIN_MS = 3e3;
|
|
23092
|
+
/**
|
|
23093
|
+
* How long to wait for `/ready`. Its database check waits up to the pool's
|
|
23094
|
+
* acquire timeout before it reports `database: false`, so the wait covers that
|
|
23095
|
+
* timeout plus a margin. An acquire timeout of 0 lets the pool wait without
|
|
23096
|
+
* limit; the default then bounds the wait.
|
|
23097
|
+
*/
|
|
23098
|
+
function readinessTimeoutMs(envContent) {
|
|
23099
|
+
const configured = Number(readEnvValue(envContent, "KICI_DB_POOL_ACQUIRE_TIMEOUT_MS"));
|
|
23100
|
+
return (Number.isFinite(configured) && configured > 0 ? configured : DB_POOL_ACQUIRE_TIMEOUT_DEFAULT_MS) + READINESS_MARGIN_MS;
|
|
23101
|
+
}
|
|
23102
|
+
/** Column the values of the orchestrator section start at. */
|
|
23103
|
+
const VALUE_COLUMN$1 = 12;
|
|
22244
23104
|
/** The scaler config the env file names, by path first and directory second. */
|
|
22245
23105
|
function readScalerConfig(envContent) {
|
|
22246
23106
|
return readEnvValue(envContent, "KICI_SCALER_CONFIG_PATH") ?? readEnvValue(envContent, "KICI_SCALER_CONFIG_DIR");
|
|
@@ -22261,21 +23121,14 @@ function formatConfigPaths(args) {
|
|
|
22261
23121
|
if (args.platform === "compose") lines.push(`Compose file: ${composeFilePath(args.envFilePath, args.serviceName)}`);
|
|
22262
23122
|
return lines;
|
|
22263
23123
|
}
|
|
22264
|
-
/**
|
|
22265
|
-
|
|
22266
|
-
|
|
22267
|
-
|
|
22268
|
-
|
|
22269
|
-
const res = await fetch(`http://localhost:${port}/health`, { signal: controller.signal });
|
|
22270
|
-
clearTimeout(timeout);
|
|
22271
|
-
if (!res.ok) return null;
|
|
22272
|
-
return await res.json();
|
|
22273
|
-
} catch {
|
|
22274
|
-
return null;
|
|
22275
|
-
}
|
|
23124
|
+
/** `yes` when every readiness check passed, otherwise `no` and the failing checks. */
|
|
23125
|
+
function formatReadiness(readiness) {
|
|
23126
|
+
if (readiness.status === ReadinessStatus.Ready) return "yes";
|
|
23127
|
+
const failing = Object.entries(readiness.checks ?? {}).filter(([, passed]) => !passed).map(([name]) => name);
|
|
23128
|
+
return failing.length > 0 ? `no (failing: ${failing.join(", ")})` : "no";
|
|
22276
23129
|
}
|
|
22277
23130
|
/** Format the status output as a readable table. */
|
|
22278
|
-
function formatStatus$1(serviceStatus, health, serviceName) {
|
|
23131
|
+
function formatStatus$1(serviceStatus, health, readiness, serviceName) {
|
|
22279
23132
|
const lines = [];
|
|
22280
23133
|
lines.push(`Service: ${serviceName}`);
|
|
22281
23134
|
lines.push(`State: ${serviceStatus.state}`);
|
|
@@ -22283,18 +23136,11 @@ function formatStatus$1(serviceStatus, health, serviceName) {
|
|
|
22283
23136
|
if (serviceStatus.uptime != null) lines.push(`Uptime: ${formatUptime(serviceStatus.uptime)}`);
|
|
22284
23137
|
if (serviceStatus.startedAt) lines.push(`Started: ${serviceStatus.startedAt}`);
|
|
22285
23138
|
if (health) {
|
|
22286
|
-
|
|
22287
|
-
|
|
22288
|
-
|
|
22289
|
-
|
|
22290
|
-
|
|
22291
|
-
if (health.platformRelay) lines.push(`Platform relay: ${health.platformRelay}`);
|
|
22292
|
-
if (health.agents != null) lines.push(`Agents: ${health.agents}`);
|
|
22293
|
-
if (health.scaler) {
|
|
22294
|
-
const s = health.scaler;
|
|
22295
|
-
lines.push(`Scaler: ${s.type ?? "none"} (warm: ${s.warm ?? 0}, max: ${s.max ?? 0})`);
|
|
22296
|
-
}
|
|
22297
|
-
if (health.jobs) lines.push(`Jobs: ${health.jobs.pending ?? 0} pending, ${health.jobs.running ?? 0} running`);
|
|
23139
|
+
const rows = [];
|
|
23140
|
+
if (health.status) rows.push(["Health", health.status]);
|
|
23141
|
+
rows.push(["Ready", readiness ? formatReadiness(readiness) : "unknown (/ready did not answer)"]);
|
|
23142
|
+
rows.push(...buildInfoRows(health));
|
|
23143
|
+
lines.push(...formatHealthSection("--- KiCI orchestrator ---", rows, VALUE_COLUMN$1));
|
|
22298
23144
|
} else if (serviceStatus.state === "running") {
|
|
22299
23145
|
lines.push("");
|
|
22300
23146
|
lines.push("(Could not reach health API)");
|
|
@@ -22302,12 +23148,13 @@ function formatStatus$1(serviceStatus, health, serviceName) {
|
|
|
22302
23148
|
return lines.join("\n");
|
|
22303
23149
|
}
|
|
22304
23150
|
/** Build JSON output combining service + health data. */
|
|
22305
|
-
function buildJsonOutput$1(serviceStatus, health, serviceName, configPaths) {
|
|
23151
|
+
function buildJsonOutput$1(serviceStatus, health, readiness, serviceName, configPaths) {
|
|
22306
23152
|
return {
|
|
22307
23153
|
service: serviceName,
|
|
22308
23154
|
...serviceStatus,
|
|
22309
23155
|
configPaths,
|
|
22310
|
-
health: health
|
|
23156
|
+
health: health ? hideBuildCommit(health) : void 0,
|
|
23157
|
+
readiness: readiness ?? void 0
|
|
22311
23158
|
};
|
|
22312
23159
|
}
|
|
22313
23160
|
function registerStatusCommand(parent) {
|
|
@@ -22346,14 +23193,21 @@ function registerStatusCommand(parent) {
|
|
|
22346
23193
|
envContent
|
|
22347
23194
|
});
|
|
22348
23195
|
let health = null;
|
|
22349
|
-
|
|
23196
|
+
let readiness = null;
|
|
23197
|
+
if (serviceStatus.state === "running") {
|
|
23198
|
+
const endpoint = readLocalEndpoint(envContent, ORCHESTRATOR_DEFAULT_PORT);
|
|
23199
|
+
[health, readiness] = await Promise.all([fetchLocalJson(endpoint, "/health"), fetchLocalJson(endpoint, "/ready", {
|
|
23200
|
+
acceptStatuses: [503],
|
|
23201
|
+
timeoutMs: readinessTimeoutMs(envContent)
|
|
23202
|
+
})]);
|
|
23203
|
+
}
|
|
22350
23204
|
const jsonConfigPaths = { envFile: config.envFilePath };
|
|
22351
23205
|
const scalerPath = readScalerConfig(envContent);
|
|
22352
23206
|
if (scalerPath) jsonConfigPaths.scalerConfig = scalerPath;
|
|
22353
23207
|
if (installPlatform === "compose") jsonConfigPaths.composeFile = composeFilePath(config.envFilePath, config.name);
|
|
22354
|
-
if (opts.json) console.log(JSON.stringify(buildJsonOutput$1(serviceStatus, health, config.name, jsonConfigPaths), null, 2));
|
|
23208
|
+
if (opts.json) console.log(JSON.stringify(buildJsonOutput$1(serviceStatus, health, readiness, config.name, jsonConfigPaths), null, 2));
|
|
22355
23209
|
else {
|
|
22356
|
-
console.log(formatStatus$1(serviceStatus, health, config.name));
|
|
23210
|
+
console.log(formatStatus$1(serviceStatus, health, readiness, config.name));
|
|
22357
23211
|
console.log("");
|
|
22358
23212
|
console.log(configPathLines.join("\n"));
|
|
22359
23213
|
}
|
|
@@ -22520,6 +23374,26 @@ function resolveDatabaseUrl$2(envFilePath, readFile) {
|
|
|
22520
23374
|
return url;
|
|
22521
23375
|
}
|
|
22522
23376
|
//#endregion
|
|
23377
|
+
//#region src/agent-packaging/node-runtime-members.ts
|
|
23378
|
+
/**
|
|
23379
|
+
* The members of an official Node.js Linux or macOS archive that a KiCI payload
|
|
23380
|
+
* vendors, relative to the archive's `node-v<version>-<os>-<arch>/` directory:
|
|
23381
|
+
* the `node` binary, the `npm` and `npx` launchers, and the bundled npm package.
|
|
23382
|
+
* With them in place, the agent's `resolveNpm()` finds
|
|
23383
|
+
* `<nodeDir>/../lib/node_modules/npm/bin/npm-cli.js` beside the vendored `node`,
|
|
23384
|
+
* so the builder role needs no npm on the host.
|
|
23385
|
+
*
|
|
23386
|
+
* This module has no imports so that repository tooling can read the list:
|
|
23387
|
+
* `scripts/lib/node-dist.mjs` (the standalone package builder) keeps its own
|
|
23388
|
+
* copy, and its test pins the two lists equal.
|
|
23389
|
+
*/
|
|
23390
|
+
const NODE_RUNTIME_MEMBERS = [
|
|
23391
|
+
"bin/node",
|
|
23392
|
+
"bin/npm",
|
|
23393
|
+
"bin/npx",
|
|
23394
|
+
"lib/node_modules/npm"
|
|
23395
|
+
];
|
|
23396
|
+
//#endregion
|
|
22523
23397
|
//#region src/agent-packaging/node-binary.ts
|
|
22524
23398
|
/**
|
|
22525
23399
|
* Fetch a Node dist URL, retrying transient failures. The runtime download is a
|
|
@@ -22578,12 +23452,7 @@ async function downloadNodeBinary(opts) {
|
|
|
22578
23452
|
const archivePath = path.join(prefixDir, filename);
|
|
22579
23453
|
writeFileSync(archivePath, buf);
|
|
22580
23454
|
const inner = `node-v${opts.version}-${nodeOs}-${nodeArch}`;
|
|
22581
|
-
const members =
|
|
22582
|
-
`${inner}/bin/node`,
|
|
22583
|
-
`${inner}/bin/npm`,
|
|
22584
|
-
`${inner}/bin/npx`,
|
|
22585
|
-
`${inner}/lib/node_modules/npm`
|
|
22586
|
-
];
|
|
23455
|
+
const members = NODE_RUNTIME_MEMBERS.map((member) => `${inner}/${member}`);
|
|
22587
23456
|
const out = $.sync({
|
|
22588
23457
|
nothrow: true,
|
|
22589
23458
|
stdio: "pipe"
|
|
@@ -22802,7 +23671,7 @@ init_manifest();
|
|
|
22802
23671
|
const ALL_PLATFORMS = AgentPlatform.options;
|
|
22803
23672
|
/** The orchestrator's own version (single-version invariant) = the packaged agent version. */
|
|
22804
23673
|
function resolveKiciVersion() {
|
|
22805
|
-
return "0.
|
|
23674
|
+
return "0.12.0";
|
|
22806
23675
|
}
|
|
22807
23676
|
/** Parse the --platform value: default set | single | CSV | `all`. */
|
|
22808
23677
|
function parsePlatforms(raw) {
|
|
@@ -22986,6 +23855,17 @@ function registerOrchestratorServiceCommands(program, getClient, tryGetClient) {
|
|
|
22986
23855
|
registerOrchestratorDrain(orchestrator, getClient);
|
|
22987
23856
|
}
|
|
22988
23857
|
//#endregion
|
|
23858
|
+
//#region src/cli/commands/agent-service/agent-env.ts
|
|
23859
|
+
/** Render the env file, one `KEY=value` line per field that was given. */
|
|
23860
|
+
function renderAgentEnvFile(fields) {
|
|
23861
|
+
const lines = [fields.header];
|
|
23862
|
+
if (fields.orchestratorUrl) lines.push(`KICI_ORCHESTRATOR_URL=${fields.orchestratorUrl}`);
|
|
23863
|
+
if (fields.token) lines.push(`KICI_AGENT_TOKEN=${fields.token}`);
|
|
23864
|
+
if (fields.labels && fields.labels.length > 0) lines.push(`KICI_LABELS=${fields.labels.join(",")}`);
|
|
23865
|
+
if (fields.port !== void 0) lines.push(`KICI_PORT=${fields.port}`);
|
|
23866
|
+
return `${lines.join("\n")}\n`;
|
|
23867
|
+
}
|
|
23868
|
+
//#endregion
|
|
22989
23869
|
//#region src/cli/wizard/agent-wizard.ts
|
|
22990
23870
|
var agent_wizard_exports = /* @__PURE__ */ __exportAll({ runAgentWizard: () => runAgentWizard });
|
|
22991
23871
|
/**
|
|
@@ -23006,13 +23886,13 @@ async function runAgentWizard() {
|
|
|
23006
23886
|
const agentToken = await promptSecret("Agent authentication token:");
|
|
23007
23887
|
console.log("");
|
|
23008
23888
|
const labels = (await input({
|
|
23009
|
-
message: "Agent labels (comma-separated
|
|
23889
|
+
message: "Agent labels (comma-separated; empty = only the kici:* labels every agent reports):",
|
|
23010
23890
|
default: ""
|
|
23011
23891
|
})).split(",").map((l) => l.trim()).filter(Boolean);
|
|
23012
23892
|
console.log("");
|
|
23013
23893
|
console.log("Configuration complete. Summary:");
|
|
23014
23894
|
console.log(` Orchestrator: ${orchestratorUrl}`);
|
|
23015
|
-
console.log(` Labels: ${labels.length > 0 ? labels.join(", ") : "(none
|
|
23895
|
+
console.log(` Labels: ${labels.length > 0 ? labels.join(", ") : "(none; only the kici:* labels every agent reports)"}`);
|
|
23016
23896
|
console.log("");
|
|
23017
23897
|
return {
|
|
23018
23898
|
orchestratorUrl,
|
|
@@ -23026,13 +23906,26 @@ var init_agent_wizard = __esmMin((() => {
|
|
|
23026
23906
|
//#endregion
|
|
23027
23907
|
//#region src/cli/commands/agent-service/install.ts
|
|
23028
23908
|
init_service();
|
|
23909
|
+
/** `--port` as a TCP port number; exits with an error for anything else. */
|
|
23910
|
+
function parsePort(value) {
|
|
23911
|
+
if (value === void 0) return void 0;
|
|
23912
|
+
const port = /^\d+$/.test(value) ? Number(value) : NaN;
|
|
23913
|
+
if (!(port >= 1 && port <= 65535)) {
|
|
23914
|
+
console.error(`Error: --port must be a TCP port between 1 and 65535 (got "${value}")`);
|
|
23915
|
+
process.exit(1);
|
|
23916
|
+
}
|
|
23917
|
+
return port;
|
|
23918
|
+
}
|
|
23029
23919
|
function registerAgentInstall(agent) {
|
|
23030
|
-
agent.command("install").description("Install the agent as a system service").option("--platform <type>", "Force the service platform (systemd, launchd, windows, compose). Default: detected from the host").option("--env-file <path>", "Path to existing env/config file to use").option("--binary <path>", "Path to agent binary (default: current executable)").option("--name <name>", "Service name", "kici-agent").option("--orchestrator-url <url>", "URL of the orchestrator to connect to").option("--token <token>", "Agent authentication token").option("--labels <labels>", "Comma-separated agent labels for routing").option("--wizard", "Interactive wizard for guided setup").option("--system", "Install as system-level service (requires root)").option("--user-level", "Install as user-level service (no root required)").option("--instance-dir <path>", "Deploy folder; the instance manifest is written here (default: current working directory)").option("--force", "Overwrite an existing same-named foreign instance").action(async (opts) => {
|
|
23920
|
+
agent.command("install").description("Install the agent as a system service").option("--platform <type>", "Force the service platform (systemd, launchd, windows, compose). Default: detected from the host").option("--env-file <path>", "Path to existing env/config file to use").option("--binary <path>", "Path to agent binary (default: current executable)").option("--name <name>", "Service name", "kici-agent").option("--orchestrator-url <url>", "URL of the orchestrator to connect to").option("--token <token>", "Agent authentication token").option("--labels <labels>", "Comma-separated agent labels for routing").option("--port <port>", `HTTP port for the agent's health and metrics endpoints; each agent on one host needs its own (default: ${AGENT_DEFAULT_PORT})`).option("--wizard", "Interactive wizard for guided setup").option("--system", "Install as system-level service (requires root)").option("--user-level", "Install as user-level service (no root required)").option("--instance-dir <path>", "Deploy folder; the instance manifest is written here (default: current working directory)").option("--force", "Overwrite an existing same-named foreign instance").action(async (opts) => {
|
|
23031
23921
|
try {
|
|
23922
|
+
const resolveModule = typeof import.meta.resolve === "function" ? (specifier) => import.meta.resolve(specifier) : void 0;
|
|
23923
|
+
assertServerResolvable("agent", opts.binary, resolveModule);
|
|
23032
23924
|
if (opts.wizard && opts.envFile) {
|
|
23033
23925
|
console.error("Error: Cannot use --wizard with --env-file");
|
|
23034
23926
|
process.exit(1);
|
|
23035
23927
|
}
|
|
23928
|
+
const port = parsePort(opts.port);
|
|
23036
23929
|
const platform = detectPlatform(opts.platform);
|
|
23037
23930
|
const userLevel = resolveUserLevel(opts);
|
|
23038
23931
|
const serviceName = opts.name;
|
|
@@ -23058,14 +23951,17 @@ function registerAgentInstall(agent) {
|
|
|
23058
23951
|
fs.mkdirSync(configDir, { recursive: true });
|
|
23059
23952
|
fs.mkdirSync(logDir, { recursive: true });
|
|
23060
23953
|
const envFilePath = path.join(configDir, `${serviceName}.env`);
|
|
23954
|
+
if (platform === "windows" && !userLevel) restrictEnvFileAccess(envFilePath);
|
|
23061
23955
|
if (opts.wizard) {
|
|
23062
23956
|
const { runAgentWizard } = await Promise.resolve().then(() => (init_agent_wizard(), agent_wizard_exports));
|
|
23063
23957
|
const wizardConfig = await runAgentWizard();
|
|
23064
|
-
|
|
23065
|
-
|
|
23066
|
-
|
|
23067
|
-
|
|
23068
|
-
|
|
23958
|
+
writeFileSecurelySync(envFilePath, renderAgentEnvFile({
|
|
23959
|
+
header: "# KiCI agent configuration (generated by setup wizard)",
|
|
23960
|
+
orchestratorUrl: wizardConfig.orchestratorUrl,
|
|
23961
|
+
token: wizardConfig.agentToken,
|
|
23962
|
+
labels: wizardConfig.labels,
|
|
23963
|
+
port
|
|
23964
|
+
}), 384);
|
|
23069
23965
|
console.log(`Wrote wizard configuration to ${envFilePath}`);
|
|
23070
23966
|
} else if (opts.envFile) {
|
|
23071
23967
|
const source = path.resolve(opts.envFile);
|
|
@@ -23076,15 +23972,17 @@ function registerAgentInstall(agent) {
|
|
|
23076
23972
|
copyFileSecurelySync(source, envFilePath, 384);
|
|
23077
23973
|
console.log(`Copied env file to ${envFilePath}`);
|
|
23078
23974
|
} else if (!fs.existsSync(envFilePath)) {
|
|
23079
|
-
|
|
23080
|
-
|
|
23081
|
-
|
|
23082
|
-
|
|
23083
|
-
|
|
23975
|
+
writeFileSecurelySync(envFilePath, renderAgentEnvFile({
|
|
23976
|
+
header: "# KiCI agent configuration",
|
|
23977
|
+
orchestratorUrl: opts.orchestratorUrl,
|
|
23978
|
+
token: opts.token,
|
|
23979
|
+
labels: opts.labels?.split(",").map((l) => l.trim()).filter(Boolean),
|
|
23980
|
+
port
|
|
23981
|
+
}), 384);
|
|
23084
23982
|
console.log(`Created env file at ${envFilePath}`);
|
|
23085
23983
|
}
|
|
23086
23984
|
if (fs.existsSync(envFilePath)) fs.chmodSync(envFilePath, 384);
|
|
23087
|
-
const entryScript = opts.binary ? void 0 :
|
|
23985
|
+
const entryScript = opts.binary ? void 0 : resolveServerScript("@kici-dev/agent/server", "agent", resolveModule);
|
|
23088
23986
|
const { executablePath, args } = resolveServiceExecutable({
|
|
23089
23987
|
binary: opts.binary ? path.resolve(opts.binary) : void 0,
|
|
23090
23988
|
nodePath: process.execPath,
|
|
@@ -23092,8 +23990,7 @@ function registerAgentInstall(agent) {
|
|
|
23092
23990
|
});
|
|
23093
23991
|
const config = {
|
|
23094
23992
|
name: serviceName,
|
|
23095
|
-
|
|
23096
|
-
description: "KiCI CI/CD workflow execution agent service",
|
|
23993
|
+
...SERVICE_TEXT.agent,
|
|
23097
23994
|
executablePath,
|
|
23098
23995
|
args,
|
|
23099
23996
|
nodeBinDir: path.dirname(process.execPath),
|
|
@@ -23105,7 +24002,7 @@ function registerAgentInstall(agent) {
|
|
|
23105
24002
|
restartPolicy: DEFAULT_RESTART_POLICY
|
|
23106
24003
|
};
|
|
23107
24004
|
await manager.install(config);
|
|
23108
|
-
const manifestFile =
|
|
24005
|
+
const { file: manifestFile, warning: manifestWarning } = writeInstallManifest(instanceDir, {
|
|
23109
24006
|
component: "agent",
|
|
23110
24007
|
name: serviceName,
|
|
23111
24008
|
platform,
|
|
@@ -23116,7 +24013,11 @@ function registerAgentInstall(agent) {
|
|
|
23116
24013
|
installBase: getInstallBase(platform, serviceName),
|
|
23117
24014
|
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
23118
24015
|
kiciVersion: readKiciVersion()
|
|
24016
|
+
}, {
|
|
24017
|
+
execPath: executablePath,
|
|
24018
|
+
args
|
|
23119
24019
|
});
|
|
24020
|
+
if (manifestWarning) console.warn(`Warning: ${manifestWarning}`);
|
|
23120
24021
|
try {
|
|
23121
24022
|
appendIndexEntry(kiciRoot, {
|
|
23122
24023
|
component: "agent",
|
|
@@ -23328,37 +24229,8 @@ function registerAgentRestart(agent) {
|
|
|
23328
24229
|
//#endregion
|
|
23329
24230
|
//#region src/cli/commands/agent-service/status.ts
|
|
23330
24231
|
init_service();
|
|
23331
|
-
/**
|
|
23332
|
-
|
|
23333
|
-
if (!fs.existsSync(envFilePath)) return 4001;
|
|
23334
|
-
try {
|
|
23335
|
-
const content = fs.readFileSync(envFilePath, "utf-8");
|
|
23336
|
-
for (const line of content.split("\n")) {
|
|
23337
|
-
const trimmed = line.trim();
|
|
23338
|
-
if (trimmed.startsWith("#") || !trimmed.includes("=")) continue;
|
|
23339
|
-
const [key, ...rest] = trimmed.split("=");
|
|
23340
|
-
if (key?.trim() === "KICI_AGENT_PORT" || key?.trim() === "KICI_PORT") {
|
|
23341
|
-
const val = rest.join("=").trim().replace(/^["']|["']$/g, "");
|
|
23342
|
-
const parsed = parseInt(val, 10);
|
|
23343
|
-
if (!isNaN(parsed)) return parsed;
|
|
23344
|
-
}
|
|
23345
|
-
}
|
|
23346
|
-
} catch {}
|
|
23347
|
-
return 4001;
|
|
23348
|
-
}
|
|
23349
|
-
/** Query agent health API. */
|
|
23350
|
-
async function queryHealth(port) {
|
|
23351
|
-
try {
|
|
23352
|
-
const controller = new AbortController();
|
|
23353
|
-
const timeout = setTimeout(() => controller.abort(), 3e3);
|
|
23354
|
-
const res = await fetch(`http://localhost:${port}/health`, { signal: controller.signal });
|
|
23355
|
-
clearTimeout(timeout);
|
|
23356
|
-
if (!res.ok) return null;
|
|
23357
|
-
return await res.json();
|
|
23358
|
-
} catch {
|
|
23359
|
-
return null;
|
|
23360
|
-
}
|
|
23361
|
-
}
|
|
24232
|
+
/** Column the values of the agent section start at. */
|
|
24233
|
+
const VALUE_COLUMN = 14;
|
|
23362
24234
|
function formatStatus(serviceStatus, health, serviceName) {
|
|
23363
24235
|
const lines = [];
|
|
23364
24236
|
lines.push(`Service: ${serviceName}`);
|
|
@@ -23367,13 +24239,12 @@ function formatStatus(serviceStatus, health, serviceName) {
|
|
|
23367
24239
|
if (serviceStatus.uptime != null) lines.push(`Uptime: ${formatUptime(serviceStatus.uptime)}`);
|
|
23368
24240
|
if (serviceStatus.startedAt) lines.push(`Started: ${serviceStatus.startedAt}`);
|
|
23369
24241
|
if (health) {
|
|
23370
|
-
|
|
23371
|
-
|
|
23372
|
-
if (
|
|
23373
|
-
|
|
23374
|
-
|
|
23375
|
-
|
|
23376
|
-
else lines.push("Current job: idle");
|
|
24242
|
+
const rows = [];
|
|
24243
|
+
if (health.agentId) rows.push(["Agent ID", health.agentId]);
|
|
24244
|
+
if (typeof health.connected === "boolean") rows.push(["Orchestrator", health.connected ? "connected" : "disconnected"]);
|
|
24245
|
+
if (typeof health.activeJobs === "number") rows.push(["Active jobs", String(health.activeJobs)]);
|
|
24246
|
+
rows.push(...buildInfoRows(health));
|
|
24247
|
+
lines.push(...formatHealthSection("--- KiCI agent ---", rows, VALUE_COLUMN));
|
|
23377
24248
|
} else if (serviceStatus.state === "running") {
|
|
23378
24249
|
lines.push("");
|
|
23379
24250
|
lines.push("(Could not reach agent health API)");
|
|
@@ -23384,7 +24255,7 @@ function buildJsonOutput(serviceStatus, health, serviceName) {
|
|
|
23384
24255
|
return {
|
|
23385
24256
|
service: serviceName,
|
|
23386
24257
|
...serviceStatus,
|
|
23387
|
-
health: health
|
|
24258
|
+
health: health ? hideBuildCommit(health) : void 0
|
|
23388
24259
|
};
|
|
23389
24260
|
}
|
|
23390
24261
|
function registerAgentStatusCommand(parent) {
|
|
@@ -23416,7 +24287,7 @@ function registerAgentStatusCommand(parent) {
|
|
|
23416
24287
|
};
|
|
23417
24288
|
const serviceStatus = await manager.status(config);
|
|
23418
24289
|
let health = null;
|
|
23419
|
-
if (serviceStatus.state === "running") health = await
|
|
24290
|
+
if (serviceStatus.state === "running") health = await fetchLocalJson(readLocalEndpoint(readEnvContent(config.envFilePath), AGENT_DEFAULT_PORT), "/health");
|
|
23420
24291
|
if (opts.json) console.log(JSON.stringify(buildJsonOutput(serviceStatus, health, config.name), null, 2));
|
|
23421
24292
|
else console.log(formatStatus(serviceStatus, health, config.name));
|
|
23422
24293
|
} catch (err) {
|
|
@@ -26253,7 +27124,7 @@ function registerClusterNameCommands(program, getClient) {
|
|
|
26253
27124
|
* (`cluster_meta.cluster_id`) and the durable S3 sentinel
|
|
26254
27125
|
* (`<prefix>/.kici-cluster-id`).
|
|
26255
27126
|
*
|
|
26256
|
-
* Used by `kici-admin cluster reconcile-identity
|
|
27127
|
+
* Used by `kici-admin cluster reconcile-identity`, which a deploy can run as a
|
|
26257
27128
|
* pre-orchestrator-start self-heal step. Talks DB + S3 directly (never the
|
|
26258
27129
|
* orchestrator HTTP admin API) so it works while the orchestrator process is
|
|
26259
27130
|
* down — which is exactly when a "Cluster identity mismatch" boot failure needs
|
|
@@ -26450,9 +27321,8 @@ function registerClusterCommands(program) {
|
|
|
26450
27321
|
*
|
|
26451
27322
|
* Direct-DB mode: activated when `--database-url` is passed (or
|
|
26452
27323
|
* KICI_DATABASE_URL / DATABASE_URL is set). Opens its own pool and runs the
|
|
26453
|
-
* SQL directly.
|
|
26454
|
-
*
|
|
26455
|
-
* can't crash it on boot.
|
|
27324
|
+
* SQL directly. Use it for cleanup while the orchestrator is deliberately
|
|
27325
|
+
* stopped, so a stale scoped-secret can't crash it on boot.
|
|
26456
27326
|
*/
|
|
26457
27327
|
function resolveDirectDbUrl$5(explicit) {
|
|
26458
27328
|
return explicit ?? process.env.KICI_DATABASE_URL ?? null;
|
|
@@ -26832,6 +27702,72 @@ function registerContextCommands(program, getClient) {
|
|
|
26832
27702
|
process.exit(1);
|
|
26833
27703
|
}
|
|
26834
27704
|
});
|
|
27705
|
+
registerSourceOverrideCommands(env, getClient);
|
|
27706
|
+
}
|
|
27707
|
+
function printSourceOverrides(rows) {
|
|
27708
|
+
if (rows.length === 0) {
|
|
27709
|
+
console.log("No source overrides found.");
|
|
27710
|
+
return;
|
|
27711
|
+
}
|
|
27712
|
+
const header = [
|
|
27713
|
+
"ROUTING KEY",
|
|
27714
|
+
"KEY",
|
|
27715
|
+
"VALUE"
|
|
27716
|
+
];
|
|
27717
|
+
const data = rows.map((r) => [
|
|
27718
|
+
r.routing_key,
|
|
27719
|
+
r.key,
|
|
27720
|
+
r.value
|
|
27721
|
+
]);
|
|
27722
|
+
const widths = header.map((h, i) => Math.max(h.length, ...data.map((row) => row[i].length)));
|
|
27723
|
+
const pad = (s, w) => s + " ".repeat(Math.max(0, w - s.length));
|
|
27724
|
+
console.log(header.map((h, i) => pad(h, widths[i])).join(" "));
|
|
27725
|
+
for (const row of data) console.log(row.map((c, i) => pad(c, widths[i])).join(" "));
|
|
27726
|
+
}
|
|
27727
|
+
/** Admin API path of one source override, every segment URI-encoded. */
|
|
27728
|
+
function sourceOverridePath(opts) {
|
|
27729
|
+
const params = new URLSearchParams({ orgId: opts.org });
|
|
27730
|
+
return `/api/v1/admin/contexts/${encodeURIComponent(opts.env)}/source-overrides/${encodeURIComponent(opts.routingKey)}/${encodeURIComponent(opts.key)}?${params}`;
|
|
27731
|
+
}
|
|
27732
|
+
/**
|
|
27733
|
+
* `context source-override list|set|delete` — the operator path for the
|
|
27734
|
+
* dashboard's per-source override writes (`contexts.source_overrides.*` in the
|
|
27735
|
+
* dashboard-write policy). HTTP only: each verb calls the orchestrator admin API.
|
|
27736
|
+
*/
|
|
27737
|
+
function registerSourceOverrideCommands(context, getClient) {
|
|
27738
|
+
const so = context.command("source-override").description("Per-source variable overrides in a context (HTTP only)");
|
|
27739
|
+
so.command("list").description("List the source overrides in a context, for every source unless --routing-key").requiredOption("--org <id>", "Org ID").requiredOption("--env <name>", "Context name").option("--routing-key <key>", "Only list the overrides for this source routing key").option("--json", "Emit JSON output").action(async (opts) => {
|
|
27740
|
+
try {
|
|
27741
|
+
const params = new URLSearchParams({ orgId: opts.org });
|
|
27742
|
+
if (opts.routingKey) params.set("routingKey", opts.routingKey);
|
|
27743
|
+
const result = await getClient().get(`/api/v1/admin/contexts/${encodeURIComponent(opts.env)}/source-overrides?${params}`);
|
|
27744
|
+
if (opts.json) console.log(JSON.stringify(result));
|
|
27745
|
+
else printSourceOverrides(result.overrides);
|
|
27746
|
+
} catch (err) {
|
|
27747
|
+
console.error(`Error: ${toErrorMessage(err)}`);
|
|
27748
|
+
process.exit(1);
|
|
27749
|
+
}
|
|
27750
|
+
});
|
|
27751
|
+
so.command("set").description("Set the value one source sees for a context variable key. A locked context variable keeps its own value.").requiredOption("--org <id>", "Org ID").requiredOption("--env <name>", "Context name").requiredOption("--routing-key <key>", "Routing key of the source the override applies to").requiredOption("--key <key>", "Variable key").requiredOption("--value <value>", "Override value").option("--json", "Emit JSON output").action(async (opts) => {
|
|
27752
|
+
try {
|
|
27753
|
+
const result = await getClient().put(sourceOverridePath(opts), { value: opts.value });
|
|
27754
|
+
if (opts.json) console.log(JSON.stringify(result));
|
|
27755
|
+
else console.log(`context source-override set: key=${opts.key} routing-key=${opts.routingKey} (env=${opts.env})`);
|
|
27756
|
+
} catch (err) {
|
|
27757
|
+
console.error(`Error: ${toErrorMessage(err)}`);
|
|
27758
|
+
process.exit(1);
|
|
27759
|
+
}
|
|
27760
|
+
});
|
|
27761
|
+
so.command("delete").description("Delete one source override, so the source resolves the key from the context variables").requiredOption("--org <id>", "Org ID").requiredOption("--env <name>", "Context name").requiredOption("--routing-key <key>", "Routing key of the source the override applies to").requiredOption("--key <key>", "Variable key").option("--json", "Emit JSON output").action(async (opts) => {
|
|
27762
|
+
try {
|
|
27763
|
+
const result = await getClient().delete(sourceOverridePath(opts));
|
|
27764
|
+
if (opts.json) console.log(JSON.stringify(result));
|
|
27765
|
+
else console.log(`context source-override delete: key=${opts.key} routing-key=${opts.routingKey} (env=${opts.env})`);
|
|
27766
|
+
} catch (err) {
|
|
27767
|
+
console.error(`Error: ${toErrorMessage(err)}`);
|
|
27768
|
+
process.exit(1);
|
|
27769
|
+
}
|
|
27770
|
+
});
|
|
26835
27771
|
}
|
|
26836
27772
|
//#endregion
|
|
26837
27773
|
//#region src/cli/commands/variable.ts
|
|
@@ -26886,7 +27822,7 @@ function registerVariableCommands(program, getClient) {
|
|
|
26886
27822
|
vr.command("delete <orgId> <context> <key>").description("Delete a context variable").option("--yes", "Skip confirmation prompt").action(async (orgId, context, key, opts) => {
|
|
26887
27823
|
try {
|
|
26888
27824
|
if (!opts.yes) {
|
|
26889
|
-
if (!await
|
|
27825
|
+
if (!await confirmPrompt(`Are you sure you want to delete variable '${key}' from context '${context}'? [y/N] `)) {
|
|
26890
27826
|
console.log("Aborted.");
|
|
26891
27827
|
return;
|
|
26892
27828
|
}
|
|
@@ -26899,19 +27835,6 @@ function registerVariableCommands(program, getClient) {
|
|
|
26899
27835
|
}
|
|
26900
27836
|
});
|
|
26901
27837
|
}
|
|
26902
|
-
async function confirm$1(message) {
|
|
26903
|
-
const { createInterface } = await import("node:readline");
|
|
26904
|
-
const rl = createInterface({
|
|
26905
|
-
input: process.stdin,
|
|
26906
|
-
output: process.stderr
|
|
26907
|
-
});
|
|
26908
|
-
return new Promise((resolve) => {
|
|
26909
|
-
rl.question(`${message} [y/N] `, (answer) => {
|
|
26910
|
-
rl.close();
|
|
26911
|
-
resolve(answer.toLowerCase() === "y" || answer.toLowerCase() === "yes");
|
|
26912
|
-
});
|
|
26913
|
-
});
|
|
26914
|
-
}
|
|
26915
27838
|
//#endregion
|
|
26916
27839
|
//#region src/cli/commands/queue.ts
|
|
26917
27840
|
function resolveDirectDbUrl$3(explicit) {
|
|
@@ -27214,7 +28137,7 @@ function printRegistrationShow(res) {
|
|
|
27214
28137
|
console.log(`updated_at: ${r.updated_at}`);
|
|
27215
28138
|
}
|
|
27216
28139
|
function registerRegistrationCommands(program, getClient) {
|
|
27217
|
-
const reg = program.command("registration").description("Registered workflow
|
|
28140
|
+
const reg = program.command("registration").description("Registered workflow instances (workflow_registrations table)");
|
|
27218
28141
|
reg.command("list").description("List workflow_registrations rows (also returns registry_version)").option("--org <id>", "Filter by customer_id").option("--routing-key <k>", "Filter by routing_key").option("--repo <ident>", "Filter by repo_identifier").option("--trigger-type <type>", "Filter by trigger type (in trigger_types[])").option("--limit <n>", "Max rows (default 100, max 1000)").option("--database-url <url>", "Use direct DB access instead of HTTP (offline mode)").option("--json", "Emit JSON output").action(async (opts) => {
|
|
27219
28142
|
try {
|
|
27220
28143
|
const limit = parseIntOption(opts.limit, "--limit");
|
|
@@ -27257,6 +28180,42 @@ function registerRegistrationCommands(program, getClient) {
|
|
|
27257
28180
|
process.exit(1);
|
|
27258
28181
|
}
|
|
27259
28182
|
});
|
|
28183
|
+
registerRegistrationWriteCommands(reg, getClient);
|
|
28184
|
+
}
|
|
28185
|
+
/**
|
|
28186
|
+
* `registration disable|enable|delete` — the operator path for the dashboard's
|
|
28187
|
+
* `registration.disable` / `registration.delete` writes. `enable` is the same
|
|
28188
|
+
* admin route as `disable`, with the flag cleared.
|
|
28189
|
+
*/
|
|
28190
|
+
function registerRegistrationWriteCommands(reg, getClient) {
|
|
28191
|
+
const setDisabled = async (id, disabled, json) => {
|
|
28192
|
+
try {
|
|
28193
|
+
const result = await getClient().patch(`/api/v1/admin/registrations/${encodeURIComponent(id)}/disable`, { disabled });
|
|
28194
|
+
if (json) console.log(JSON.stringify(result));
|
|
28195
|
+
else console.log(`registration ${disabled ? "disable" : "enable"}: id=${id} disabled=${result.disabled} registry_version=${result.registryVersion}`);
|
|
28196
|
+
} catch (err) {
|
|
28197
|
+
console.error(`Error: ${toErrorMessage(err)}`);
|
|
28198
|
+
process.exit(1);
|
|
28199
|
+
}
|
|
28200
|
+
};
|
|
28201
|
+
reg.command("disable <id>").description("Disable a workflow registration: it stays registered, but its triggers stop dispatching").option("--json", "Emit JSON output").action(async (id, opts) => setDisabled(id, true, opts.json));
|
|
28202
|
+
reg.command("enable <id>").description("Re-enable a disabled workflow registration").option("--json", "Emit JSON output").action(async (id, opts) => setDisabled(id, false, opts.json));
|
|
28203
|
+
reg.command("delete <id>").description("Delete a workflow registration by id").option("--yes", "Skip confirmation prompt").option("--json", "Emit JSON output").action(async (id, opts) => {
|
|
28204
|
+
try {
|
|
28205
|
+
if (!opts.yes) {
|
|
28206
|
+
if (!await confirmPrompt(`Are you sure you want to delete registration '${id}'? [y/N] `)) {
|
|
28207
|
+
console.log("Aborted.");
|
|
28208
|
+
return;
|
|
28209
|
+
}
|
|
28210
|
+
}
|
|
28211
|
+
const result = await getClient().delete(`/api/v1/admin/registrations/${encodeURIComponent(id)}`);
|
|
28212
|
+
if (opts.json) console.log(JSON.stringify(result));
|
|
28213
|
+
else console.log(`registration delete: id=${id} deleted=${result.deleted} registry_version=${result.registryVersion}`);
|
|
28214
|
+
} catch (err) {
|
|
28215
|
+
console.error(`Error: ${toErrorMessage(err)}`);
|
|
28216
|
+
process.exit(1);
|
|
28217
|
+
}
|
|
28218
|
+
});
|
|
27260
28219
|
}
|
|
27261
28220
|
//#endregion
|
|
27262
28221
|
//#region src/cli/commands/event.ts
|
|
@@ -27395,7 +28354,7 @@ function registerEventDlqCommands(program, getClient) {
|
|
|
27395
28354
|
* Contract:
|
|
27396
28355
|
* - tenant column: `routing_key` (denormalized in migration 006)
|
|
27397
28356
|
* - partition column: `created_at`
|
|
27398
|
-
* - warm TTL: 30 days
|
|
28357
|
+
* - warm TTL: 30 days
|
|
27399
28358
|
* - eligibility: created_at < cutoff (steps are append-only and have no
|
|
27400
28359
|
* terminal-status predicate — there is no UPDATE statement against
|
|
27401
28360
|
* `execution_steps` in the orchestrator codebase, so age alone is
|
|
@@ -27553,7 +28512,7 @@ var init_execution_steps = __esmMin((() => {
|
|
|
27553
28512
|
* Contract:
|
|
27554
28513
|
* - tenant column: `routing_key` (denormalized in migration 006)
|
|
27555
28514
|
* - partition column: `created_at`
|
|
27556
|
-
* - warm TTL: 30 days
|
|
28515
|
+
* - warm TTL: 30 days
|
|
27557
28516
|
* - eligibility: terminal job status AND no live steps remain for this
|
|
27558
28517
|
* (run, job)
|
|
27559
28518
|
*
|
|
@@ -27728,7 +28687,7 @@ var init_execution_jobs = __esmMin((() => {
|
|
|
27728
28687
|
* Contract:
|
|
27729
28688
|
* - tenant column: `routing_key`
|
|
27730
28689
|
* - partition column: `created_at`
|
|
27731
|
-
* - warm TTL: 30 days
|
|
28690
|
+
* - warm TTL: 30 days
|
|
27732
28691
|
* - eligibility: terminal run status AND no live jobs reference this run
|
|
27733
28692
|
*
|
|
27734
28693
|
* Order: registered LAST in the orchestrator cold-store. Within one
|
|
@@ -27846,7 +28805,7 @@ var init_execution_runs = __esmMin((() => {
|
|
|
27846
28805
|
return row.created_at;
|
|
27847
28806
|
}
|
|
27848
28807
|
/**
|
|
27849
|
-
*
|
|
28808
|
+
* Natural-key extractor used by `replayRow` so callers
|
|
27850
28809
|
* (the rerun pipeline) can locate the chunk holding a given UUID
|
|
27851
28810
|
* `run_id` without knowing the internal SERIAL `id` returned by
|
|
27852
28811
|
* `rowId()`.
|
|
@@ -27855,10 +28814,10 @@ var init_execution_runs = __esmMin((() => {
|
|
|
27855
28814
|
return row.run_id;
|
|
27856
28815
|
}
|
|
27857
28816
|
/**
|
|
27858
|
-
*
|
|
28817
|
+
* Replay a chunk's `execution_runs` rows back into
|
|
27859
28818
|
* Orchestrator PG. Mirrors the Platform-side replayInsert; the
|
|
27860
28819
|
* audit row goes to `access_log` (not `audit_log`) because the
|
|
27861
|
-
* orchestrator's audit surface is access_log
|
|
28820
|
+
* orchestrator's audit surface is access_log.
|
|
27862
28821
|
*
|
|
27863
28822
|
* Idempotent on duplicate replays via `ON CONFLICT (run_id) DO
|
|
27864
28823
|
* NOTHING`. Decrements `cold_store_chunk_counts` (floored at 0).
|
|
@@ -27957,8 +28916,8 @@ var init_execution_runs = __esmMin((() => {
|
|
|
27957
28916
|
* Contract:
|
|
27958
28917
|
* - tenant column: `routing_key` (NULL → synthetic `__orchestrator__`)
|
|
27959
28918
|
* - partition column: `timestamp`
|
|
27960
|
-
* - warm TTL: 90 days (longer window than other audit tables —
|
|
27961
|
-
*
|
|
28919
|
+
* - warm TTL: 90 days (longer window than other audit tables — volume is
|
|
28920
|
+
* low, forensic value is high)
|
|
27962
28921
|
*
|
|
27963
28922
|
* Synthetic tenant: rows without `routing_key` (e.g. orchestrator-level
|
|
27964
28923
|
* key rotations done before any source is bound) collapse to a single
|
|
@@ -28091,7 +29050,7 @@ var init_secret_audit_log = __esmMin((() => {
|
|
|
28091
29050
|
return row.timestamp;
|
|
28092
29051
|
}
|
|
28093
29052
|
/**
|
|
28094
|
-
*
|
|
29053
|
+
* Per-row cold-retention TTL. Sampled `resolve` /
|
|
28095
29054
|
* `resolve_named` rows get 180d cold; mutations get `'forever'`. The
|
|
28096
29055
|
* denied-outcome override promotes to 730d (forensic).
|
|
28097
29056
|
*
|
|
@@ -28205,7 +29164,7 @@ var init_secret_audit_log = __esmMin((() => {
|
|
|
28205
29164
|
* Contract:
|
|
28206
29165
|
* - tenant column: `org_id` (NULL → synthetic `__orchestrator__`)
|
|
28207
29166
|
* - partition column: `created_at`
|
|
28208
|
-
* - warm TTL: 30 days
|
|
29167
|
+
* - warm TTL: 30 days
|
|
28209
29168
|
*
|
|
28210
29169
|
* Replaces the previous 90-day `expires_at`-based hard delete (removed
|
|
28211
29170
|
* by migration 007). Rows older than 30 days now live in S3
|
|
@@ -28346,7 +29305,7 @@ var init_access_log = __esmMin((() => {
|
|
|
28346
29305
|
return row.created_at;
|
|
28347
29306
|
}
|
|
28348
29307
|
/**
|
|
28349
|
-
*
|
|
29308
|
+
* Per-row cold-retention TTL. Mirrors the warm-side override
|
|
28350
29309
|
* layering: outcome=denied/error → 730d (forensic), platform_operator →
|
|
28351
29310
|
* `'forever'` (compliance), otherwise per-action map. The framework
|
|
28352
29311
|
* groups rows by `coldDaysToBucket(coldTtlDays(row))` at archive time.
|
|
@@ -28451,27 +29410,21 @@ var init_access_log = __esmMin((() => {
|
|
|
28451
29410
|
//#endregion
|
|
28452
29411
|
//#region src/cold-store/tables/event-log.ts
|
|
28453
29412
|
/**
|
|
28454
|
-
* `event_log` cold-store adapter (Orchestrator side)
|
|
28455
|
-
*
|
|
28456
|
-
* §2 row 6
|
|
28457
|
-
* and §10.
|
|
29413
|
+
* `event_log` cold-store adapter (Orchestrator side).
|
|
28458
29414
|
*
|
|
28459
29415
|
* Contract:
|
|
28460
29416
|
* - tenant column: `routing_key` (NOT NULL on this side — no
|
|
28461
29417
|
* synthetic-tenant fallback like access_log / secret_audit_log)
|
|
28462
29418
|
* - partition column: `received_at`
|
|
28463
|
-
* - warm TTL: 30 days (
|
|
28464
|
-
* `packages/orchestrator/src/webhook/event-log.ts:cleanup` +
|
|
28465
|
-
* `packages/orchestrator/src/queue/cleanup.ts:runCleanup` step 4)
|
|
29419
|
+
* - warm TTL: 30 days (rows are archived, not hard-deleted)
|
|
28466
29420
|
*
|
|
28467
29421
|
* Per-row payload retention: rows on this table carry a `payload_key`
|
|
28468
29422
|
* pointing at a gzipped webhook body in object storage (LogStorage).
|
|
28469
29423
|
* Cold-store packages the row metadata — including `payload_key` —
|
|
28470
29424
|
* but does NOT touch the body the key points to. The body remains in
|
|
28471
29425
|
* S3 indefinitely so the dashboard delivery-detail page resolves
|
|
28472
|
-
* payload reads identically for hot and cold rows.
|
|
28473
|
-
*
|
|
28474
|
-
* lock-step is retired.
|
|
29426
|
+
* payload reads identically for hot and cold rows. Nothing deletes the
|
|
29427
|
+
* row and the payload blob in lock-step.
|
|
28475
29428
|
*
|
|
28476
29429
|
* Recursive write: archiving event_log rows writes one access_log
|
|
28477
29430
|
* audit row per chunk (the orchestrator's audit surface). The new
|
|
@@ -28613,17 +29566,15 @@ var init_event_log = __esmMin((() => {
|
|
|
28613
29566
|
/**
|
|
28614
29567
|
* Orchestrator-side cold-store.
|
|
28615
29568
|
*
|
|
28616
|
-
* Extends BaseColdStore with orchestrator-specific table adapters
|
|
28617
|
-
*
|
|
28618
|
-
*
|
|
28619
|
-
* and access_log.
|
|
29569
|
+
* Extends BaseColdStore with orchestrator-specific table adapters:
|
|
29570
|
+
* execution_runs / execution_jobs / execution_steps, secret_audit_log,
|
|
29571
|
+
* access_log and event_log.
|
|
28620
29572
|
*/
|
|
28621
29573
|
/**
|
|
28622
29574
|
* Build the orchestrator cold-store config from env vars. Reads
|
|
28623
|
-
* `KICI_COLD_STORE_*` vars mirrored with the Platform side.
|
|
28624
|
-
*
|
|
28625
|
-
*
|
|
28626
|
-
* Phase A ships the env-var path only.
|
|
29575
|
+
* `KICI_COLD_STORE_*` vars mirrored with the Platform side. The
|
|
29576
|
+
* orchestrator config schema has no `coldStore` section, so env vars are
|
|
29577
|
+
* the only input.
|
|
28627
29578
|
*/
|
|
28628
29579
|
function readOrchestratorColdStoreConfig(env = process.env) {
|
|
28629
29580
|
const enabled = env.KICI_COLD_STORE_ENABLED === "true";
|
|
@@ -28716,7 +29667,7 @@ var init_orchestrator_cold_store = __esmMin((() => {
|
|
|
28716
29667
|
this.registerAdapter(new EventLogAdapter(deps.kdb, deps.instanceId, { overrides: deps.config.tables.event_log ?? deps.config.tables["event_log"] }));
|
|
28717
29668
|
}
|
|
28718
29669
|
/**
|
|
28719
|
-
*
|
|
29670
|
+
* Query the orchestrator's `cold_store_chunks` table for
|
|
28720
29671
|
* chunks past their per-row cold-retention horizon. See the matching
|
|
28721
29672
|
* Platform-side method for the full rationale.
|
|
28722
29673
|
*/
|
|
@@ -28749,7 +29700,7 @@ var init_orchestrator_cold_store = __esmMin((() => {
|
|
|
28749
29700
|
}));
|
|
28750
29701
|
}
|
|
28751
29702
|
/**
|
|
28752
|
-
*
|
|
29703
|
+
* Per-chunk advisory lock keyed on
|
|
28753
29704
|
* `hashtext('cold-store-purge|orchestrator|<table>|<chunkId>')`.
|
|
28754
29705
|
*/
|
|
28755
29706
|
async withPurgeLock(args, fn) {
|
|
@@ -28770,10 +29721,9 @@ var init_orchestrator_cold_store = __esmMin((() => {
|
|
|
28770
29721
|
//#endregion
|
|
28771
29722
|
//#region src/cli/commands/cold-store-impl.ts
|
|
28772
29723
|
/**
|
|
28773
|
-
*
|
|
29724
|
+
* Implementations for `kici-admin cold-store` subcommands.
|
|
28774
29725
|
*
|
|
28775
|
-
* Mirrors the Platform-side implementation
|
|
28776
|
-
* `packages/platform/src/admin/cold-store-commands.ts` — same shape,
|
|
29726
|
+
* Mirrors the Platform-side implementation — same shape,
|
|
28777
29727
|
* just builds an `OrchestratorColdStore` against the orchestrator
|
|
28778
29728
|
* Postgres + S3 instead of a `PlatformColdStore`.
|
|
28779
29729
|
*
|
|
@@ -28785,9 +29735,8 @@ var init_orchestrator_cold_store = __esmMin((() => {
|
|
|
28785
29735
|
* - prints results to stdout (JSON Lines for list-chunks, JSONL for
|
|
28786
29736
|
* peek-chunk, free-form for the others)
|
|
28787
29737
|
*
|
|
28788
|
-
*
|
|
28789
|
-
*
|
|
28790
|
-
* same shape the Platform admin CLI uses for `audit_log`.
|
|
29738
|
+
* No per-invocation breadcrumb row is written: the Platform admin CLI's
|
|
29739
|
+
* `audit_log` breadcrumb has no `access_log` counterpart here.
|
|
28791
29740
|
*/
|
|
28792
29741
|
var cold_store_impl_exports = /* @__PURE__ */ __exportAll({
|
|
28793
29742
|
archiveNow: () => archiveNow,
|
|
@@ -29050,7 +29999,7 @@ async function replayChunk(opts) {
|
|
|
29050
29999
|
}
|
|
29051
30000
|
}
|
|
29052
30001
|
/**
|
|
29053
|
-
*
|
|
30002
|
+
* Promote a chunk's rows back into the orchestrator PG.
|
|
29054
30003
|
* Mirrors the Platform-side `replayIntoPg`. Adapter must implement
|
|
29055
30004
|
* `replayInsert` — currently only `execution_runs`.
|
|
29056
30005
|
*/
|
|
@@ -29182,7 +30131,7 @@ async function reconcile(opts) {
|
|
|
29182
30131
|
}
|
|
29183
30132
|
}
|
|
29184
30133
|
/**
|
|
29185
|
-
*
|
|
30134
|
+
* List chunks past their cold-retention horizon. Read-only;
|
|
29186
30135
|
* no S3 or PG mutation. Emits one JSON line per row so the output is
|
|
29187
30136
|
* easy to pipe through `jq` / `column -t` / etc.
|
|
29188
30137
|
*/
|
|
@@ -29206,7 +30155,7 @@ async function listPurgeable(opts) {
|
|
|
29206
30155
|
}
|
|
29207
30156
|
}
|
|
29208
30157
|
/**
|
|
29209
|
-
*
|
|
30158
|
+
* Delete S3 objects + clean up PG bookkeeping for chunks
|
|
29210
30159
|
* past their cold-retention horizon. Defaults to dry-run; pass
|
|
29211
30160
|
* `--apply` to actually delete.
|
|
29212
30161
|
*/
|
|
@@ -29358,7 +30307,7 @@ function registerColdStoreCommands(program, _getClient) {
|
|
|
29358
30307
|
process.exit(2);
|
|
29359
30308
|
}
|
|
29360
30309
|
});
|
|
29361
|
-
coldStore.command("replay-into-pg <chunkId>").description("
|
|
30310
|
+
coldStore.command("replay-into-pg <chunkId>").description("Promote every row in a chunk back into orchestrator PG (clear archived_at, write a replay audit row)").option("--database-url <url>", "Orchestrator Postgres URL (else KICI_DATABASE_URL)").requiredOption("--table <table>", "Adapter table name (currently: execution_runs)").requiredOption("--tenant <rk>", "Routing key for the chunk").requiredOption("--partition-date <YYYY-MM-DD>", "Partition date for the chunk").action(async (chunkId, opts) => {
|
|
29362
30311
|
try {
|
|
29363
30312
|
const databaseUrl = resolveDatabaseUrl(opts);
|
|
29364
30313
|
const { replayIntoPg } = await Promise.resolve().then(() => (init_cold_store_impl(), cold_store_impl_exports));
|
|
@@ -29389,7 +30338,7 @@ function registerColdStoreCommands(program, _getClient) {
|
|
|
29389
30338
|
process.exit(2);
|
|
29390
30339
|
}
|
|
29391
30340
|
});
|
|
29392
|
-
coldStore.command("list-purgeable").description("
|
|
30341
|
+
coldStore.command("list-purgeable").description("List chunks past their cold-retention horizon (read-only)").option("--database-url <url>", "Orchestrator Postgres URL (else KICI_DATABASE_URL)").option("--table <table>", "Filter to a single adapter table (else all)").option("--bucket <bucket>", "Filter to a single cold-bucket (30d / 180d / 1y / 2y)").option("--limit <n>", "Max candidates to inspect", "1000").action(async (opts) => {
|
|
29393
30342
|
try {
|
|
29394
30343
|
const databaseUrl = resolveDatabaseUrl(opts);
|
|
29395
30344
|
const { listPurgeable } = await Promise.resolve().then(() => (init_cold_store_impl(), cold_store_impl_exports));
|
|
@@ -29404,7 +30353,7 @@ function registerColdStoreCommands(program, _getClient) {
|
|
|
29404
30353
|
process.exit(2);
|
|
29405
30354
|
}
|
|
29406
30355
|
});
|
|
29407
|
-
coldStore.command("purge-now").description("
|
|
30356
|
+
coldStore.command("purge-now").description("Purge expired chunks from S3 + PG bookkeeping. DRY-RUN by default — pass --apply to actually delete.").option("--database-url <url>", "Orchestrator Postgres URL (else KICI_DATABASE_URL)").option("--table <table>", "Filter to a single adapter table (else all)").option("--bucket <bucket>", "Filter to a single cold-bucket (30d / 180d / 1y / 2y)").option("--limit <n>", "Max candidates to process", "1000").option("--apply", "Actually delete (default is dry-run)").action(async (opts) => {
|
|
29408
30357
|
try {
|
|
29409
30358
|
const databaseUrl = resolveDatabaseUrl(opts);
|
|
29410
30359
|
const { purgeNow } = await Promise.resolve().then(() => (init_cold_store_impl(), cold_store_impl_exports));
|
|
@@ -29644,7 +30593,7 @@ function registerCacheCommands(program, deps = {
|
|
|
29644
30593
|
*
|
|
29645
30594
|
* A leaf module on purpose: it imports nothing, so an out-of-package consumer
|
|
29646
30595
|
* can read the constant without pulling the orchestrator runtime graph in with
|
|
29647
|
-
* it.
|
|
30596
|
+
* it. A NetworkManager drift watchdog derives its
|
|
29648
30597
|
* expected-pattern set from here rather than re-typing the patterns — a second
|
|
29649
30598
|
* copy desyncs silently the moment a pattern is added. `host-network.ts`
|
|
29650
30599
|
* re-exports it and renders `NM_CONF_CONTENT` from it, so the drop-in and the
|
|
@@ -29712,9 +30661,9 @@ function bridgePrefix(bridgeName) {
|
|
|
29712
30661
|
* serializes them.
|
|
29713
30662
|
*
|
|
29714
30663
|
* The command list NEVER deletes the table. It is a self-heal — `ensureHostReady`
|
|
29715
|
-
* runs it whenever `verifyBridge` reports unhealthy,
|
|
29716
|
-
*
|
|
29717
|
-
*
|
|
30664
|
+
* runs it whenever `verifyBridge` reports unhealthy, and none of the
|
|
30665
|
+
* conditions it checks says how many VMs are live — so dropping the table
|
|
30666
|
+
* would leave every running VM fail-open, with full RFC1918 and
|
|
29718
30667
|
* cloud-metadata reach, until it was destroyed. Instead the baseline lives in
|
|
29719
30668
|
* its own regular chain (`baseline`, see {@link BASELINE_CHAIN}) that is flushed
|
|
29720
30669
|
* and refilled, and the hooked `forward` chain keeps its per-VM rules
|
|
@@ -29844,10 +30793,9 @@ const NM_CONF_CONTENT = [
|
|
|
29844
30793
|
"#",
|
|
29845
30794
|
"# The `+=` append operator is mandatory: NM merges conf.d drop-ins last-wins",
|
|
29846
30795
|
"# per key, so a bare `unmanaged-devices=` here would be silently clobbered by",
|
|
29847
|
-
"# (or would silently clobber) any other drop-in's unmanaged-devices line
|
|
29848
|
-
"#
|
|
29849
|
-
"#
|
|
29850
|
-
"# .claude/rules/networkmanager-unmanaged.md.",
|
|
30796
|
+
"# (or would silently clobber) any other drop-in's unmanaged-devices line, such",
|
|
30797
|
+
"# as a wifi drop-in another tool installs. `+=` accumulates across files so",
|
|
30798
|
+
"# every drop-in survives.",
|
|
29851
30799
|
"[keyfile]",
|
|
29852
30800
|
...FIRECRACKER_NET_INTERFACES.map((p) => `unmanaged-devices+=interface-name:${p}`),
|
|
29853
30801
|
""
|
|
@@ -30035,6 +30983,7 @@ async function verifyBridge(cfg, opts = {}) {
|
|
|
30035
30983
|
} catch {
|
|
30036
30984
|
misses.push(`could not read chain ip ${cfg.table} forward`);
|
|
30037
30985
|
}
|
|
30986
|
+
const { natPresent, baselineRulesPresent } = await checkSubnetRules(cfg, runner, misses);
|
|
30038
30987
|
try {
|
|
30039
30988
|
const { stdout } = await runner({
|
|
30040
30989
|
bin: "ip",
|
|
@@ -30053,7 +31002,7 @@ async function verifyBridge(cfg, opts = {}) {
|
|
|
30053
31002
|
} catch {
|
|
30054
31003
|
misses.push(`could not read enslaved links on ${cfg.bridgeName}`);
|
|
30055
31004
|
}
|
|
30056
|
-
const healthy = bridgeExists && bridgeUp && addrPresent && tablePresent && baselineChainPresent && tapIsolationPresent;
|
|
31005
|
+
const healthy = bridgeExists && bridgeUp && addrPresent && tablePresent && baselineChainPresent && natPresent && baselineRulesPresent && tapIsolationPresent;
|
|
30057
31006
|
return {
|
|
30058
31007
|
bridgeName: cfg.bridgeName,
|
|
30059
31008
|
bridgeExists,
|
|
@@ -30061,6 +31010,8 @@ async function verifyBridge(cfg, opts = {}) {
|
|
|
30061
31010
|
addrPresent,
|
|
30062
31011
|
tablePresent,
|
|
30063
31012
|
baselineChainPresent,
|
|
31013
|
+
natPresent,
|
|
31014
|
+
baselineRulesPresent,
|
|
30064
31015
|
tapIsolationPresent,
|
|
30065
31016
|
healthy,
|
|
30066
31017
|
detail: healthy ? "healthy" : misses.join("; ")
|
|
@@ -30090,6 +31041,146 @@ function forwardEndsInBaselineJump(nftJson) {
|
|
|
30090
31041
|
});
|
|
30091
31042
|
}
|
|
30092
31043
|
/**
|
|
31044
|
+
* Check the two chains that carry this bridge's subnet: the `postrouting` NAT
|
|
31045
|
+
* that gives its guests internet egress, and the `baseline` rules that isolate
|
|
31046
|
+
* them.
|
|
31047
|
+
*
|
|
31048
|
+
* The table, its chains and the tail jump can all be in place while neither
|
|
31049
|
+
* rule set serves this subnet: a second bridge provisioned into the same table
|
|
31050
|
+
* flushes both chains and refills them with its own subnet. Every guest on this
|
|
31051
|
+
* bridge then loses internet access — no name resolves — and a check that stops
|
|
31052
|
+
* at the chain layout still reads the host as healthy, so the self-heal never
|
|
31053
|
+
* runs.
|
|
31054
|
+
*/
|
|
31055
|
+
async function checkSubnetRules(cfg, runner, misses) {
|
|
31056
|
+
let subnet;
|
|
31057
|
+
try {
|
|
31058
|
+
subnet = cidrToNetwork(cfg.bridgeCidr);
|
|
31059
|
+
} catch (err) {
|
|
31060
|
+
misses.push(`invalid bridge CIDR ${cfg.bridgeCidr}: ${toErrorMessage(err)}`);
|
|
31061
|
+
return {
|
|
31062
|
+
natPresent: false,
|
|
31063
|
+
baselineRulesPresent: false
|
|
31064
|
+
};
|
|
31065
|
+
}
|
|
31066
|
+
const listChain = async (chain) => {
|
|
31067
|
+
try {
|
|
31068
|
+
const { stdout } = await runner({
|
|
31069
|
+
bin: "nft",
|
|
31070
|
+
args: [
|
|
31071
|
+
"-j",
|
|
31072
|
+
"list",
|
|
31073
|
+
"chain",
|
|
31074
|
+
"ip",
|
|
31075
|
+
cfg.table,
|
|
31076
|
+
chain
|
|
31077
|
+
]
|
|
31078
|
+
});
|
|
31079
|
+
return stdout;
|
|
31080
|
+
} catch {
|
|
31081
|
+
misses.push(`could not read chain ip ${cfg.table} ${chain}`);
|
|
31082
|
+
return null;
|
|
31083
|
+
}
|
|
31084
|
+
};
|
|
31085
|
+
const postrouting = await listChain("postrouting");
|
|
31086
|
+
const natPresent = postrouting !== null && postroutingMasqueradesSubnet(postrouting, subnet);
|
|
31087
|
+
if (postrouting !== null && !natPresent) misses.push(`ip ${cfg.table} postrouting does not masquerade ${subnet}`);
|
|
31088
|
+
const baseline = await listChain(BASELINE_CHAIN);
|
|
31089
|
+
const baselineRulesPresent = baseline !== null && baselineHoldsBridgeRules(baseline, cfg);
|
|
31090
|
+
if (baseline !== null && !baselineRulesPresent) misses.push(`ip ${cfg.table} ${BASELINE_CHAIN} does not hold the ${subnet} rules`);
|
|
31091
|
+
return {
|
|
31092
|
+
natPresent,
|
|
31093
|
+
baselineRulesPresent
|
|
31094
|
+
};
|
|
31095
|
+
}
|
|
31096
|
+
const RULE_VERDICTS = [
|
|
31097
|
+
"accept",
|
|
31098
|
+
"drop",
|
|
31099
|
+
"masquerade"
|
|
31100
|
+
];
|
|
31101
|
+
/** An nft JSON address operand as `a.b.c.d` or `a.b.c.d/len`; null for any other operand. */
|
|
31102
|
+
function nftAddress(operand) {
|
|
31103
|
+
if (typeof operand === "string") return operand;
|
|
31104
|
+
const prefix = operand?.prefix;
|
|
31105
|
+
return typeof prefix?.addr === "string" && typeof prefix.len === "number" ? `${prefix.addr}/${prefix.len}` : null;
|
|
31106
|
+
}
|
|
31107
|
+
/** The rules of a `nft -j list chain` listing, in chain order; null when it does not parse. */
|
|
31108
|
+
function listedRuleShapes(nftJson) {
|
|
31109
|
+
let parsed;
|
|
31110
|
+
try {
|
|
31111
|
+
parsed = JSON.parse(nftJson);
|
|
31112
|
+
} catch {
|
|
31113
|
+
return null;
|
|
31114
|
+
}
|
|
31115
|
+
const entries = parsed?.nftables;
|
|
31116
|
+
if (!Array.isArray(entries)) return null;
|
|
31117
|
+
const shapes = [];
|
|
31118
|
+
for (const entry of entries) {
|
|
31119
|
+
const expr = entry.rule?.expr;
|
|
31120
|
+
if (!Array.isArray(expr)) continue;
|
|
31121
|
+
const shape = {
|
|
31122
|
+
saddr: null,
|
|
31123
|
+
daddr: null,
|
|
31124
|
+
verdict: null
|
|
31125
|
+
};
|
|
31126
|
+
for (const node of expr) {
|
|
31127
|
+
const match = node.match;
|
|
31128
|
+
const positive = match?.op === void 0 || match.op === "==";
|
|
31129
|
+
const payload = match && positive ? match.left?.payload : void 0;
|
|
31130
|
+
if (payload?.protocol === "ip" && payload.field === "saddr") shape.saddr = nftAddress(match.right);
|
|
31131
|
+
else if (payload?.protocol === "ip" && payload.field === "daddr") shape.daddr = nftAddress(match.right);
|
|
31132
|
+
shape.verdict = RULE_VERDICTS.find((v) => v in node) ?? shape.verdict;
|
|
31133
|
+
}
|
|
31134
|
+
shapes.push(shape);
|
|
31135
|
+
}
|
|
31136
|
+
return shapes;
|
|
31137
|
+
}
|
|
31138
|
+
/** The shape of one `nft add rule` token list the provisioner builds. */
|
|
31139
|
+
function commandRuleShape(tokens) {
|
|
31140
|
+
const shape = {
|
|
31141
|
+
saddr: null,
|
|
31142
|
+
daddr: null,
|
|
31143
|
+
verdict: null
|
|
31144
|
+
};
|
|
31145
|
+
for (let i = 0; i + 2 < tokens.length; i++) {
|
|
31146
|
+
if (tokens[i] !== "ip") continue;
|
|
31147
|
+
if (tokens[i + 1] === "saddr") shape.saddr = tokens[i + 2];
|
|
31148
|
+
if (tokens[i + 1] === "daddr") shape.daddr = tokens[i + 2];
|
|
31149
|
+
}
|
|
31150
|
+
shape.verdict = RULE_VERDICTS.find((v) => v === tokens.at(-1)) ?? null;
|
|
31151
|
+
return shape;
|
|
31152
|
+
}
|
|
31153
|
+
/** `<daddr> <verdict>` for every subnet-scoped accept or drop, in chain order (`*`: any daddr). */
|
|
31154
|
+
function subnetVerdicts(rules, subnet) {
|
|
31155
|
+
return rules.filter((r) => r.saddr === subnet && (r.verdict === "accept" || r.verdict === "drop")).map((r) => `${r.daddr ?? "*"} ${r.verdict}`);
|
|
31156
|
+
}
|
|
31157
|
+
/**
|
|
31158
|
+
* True when a `nft -j list chain … postrouting` listing masquerades traffic
|
|
31159
|
+
* whose source is `subnet` (the network CIDR, e.g. `10.0.0.0/24`).
|
|
31160
|
+
*/
|
|
31161
|
+
function postroutingMasqueradesSubnet(nftJson, subnet) {
|
|
31162
|
+
return (listedRuleShapes(nftJson) ?? []).some((r) => r.saddr === subnet && r.verdict === "masquerade");
|
|
31163
|
+
}
|
|
31164
|
+
/**
|
|
31165
|
+
* True when a `nft -j list chain … baseline` listing holds this bridge's
|
|
31166
|
+
* subnet-scoped accepts and drops exactly as {@link buildBridgeCommands} writes
|
|
31167
|
+
* them, in the same order.
|
|
31168
|
+
*
|
|
31169
|
+
* Order is part of the check: the internet accept is terminal, so ahead of the
|
|
31170
|
+
* RFC1918 and metadata drops it would let the guests reach every private range.
|
|
31171
|
+
*/
|
|
31172
|
+
function baselineHoldsBridgeRules(nftJson, cfg) {
|
|
31173
|
+
const listed = listedRuleShapes(nftJson);
|
|
31174
|
+
if (listed === null) return false;
|
|
31175
|
+
const subnet = cidrToNetwork(cfg.bridgeCidr);
|
|
31176
|
+
const want = subnetVerdicts(buildBridgeCommands({
|
|
31177
|
+
...cfg,
|
|
31178
|
+
hostIface: cfg.hostIface ?? "egress"
|
|
31179
|
+
}).filter((c) => c.bin === "nft" && c.args[0] === "add" && c.args[1] === "rule" && c.args[3] === BASELINE_CHAIN).map((c) => commandRuleShape(c.args.slice(4))), subnet);
|
|
31180
|
+
const got = subnetVerdicts(listed, subnet);
|
|
31181
|
+
return want.length === got.length && want.every((v, i) => v === got[i]);
|
|
31182
|
+
}
|
|
31183
|
+
/**
|
|
30093
31184
|
* Names of the kici-* TAPs enslaved to the bridge that are NOT port-isolated,
|
|
30094
31185
|
* read from `ip -d -j link show master <bridge>`.
|
|
30095
31186
|
*
|
|
@@ -30255,7 +31346,7 @@ function registerProvision(group) {
|
|
|
30255
31346
|
//#endregion
|
|
30256
31347
|
//#region src/cli/commands/firecracker/verify.ts
|
|
30257
31348
|
function registerVerify(group) {
|
|
30258
|
-
group.command("verify").description("Check a Firecracker host bridge
|
|
31349
|
+
group.command("verify").description("Check a Firecracker host bridge: its addr, nft table, and subnet NAT + isolation rules").requiredOption("--bridge <name>", "bridge interface name").requiredOption("--cidr <cidr>", "gateway IP + prefix").option("--table <name>", "nft table name", "kici").option("--sudo", "wrap privileged commands with sudo -n").action(async (opts) => {
|
|
30259
31350
|
const cfg = optionsToConfig(opts);
|
|
30260
31351
|
const h = await verifyBridge(cfg, { requireSudo: opts.sudo });
|
|
30261
31352
|
if (h.healthy) {
|
|
@@ -30334,6 +31425,57 @@ function isNodeError(err) {
|
|
|
30334
31425
|
return err instanceof Error && "code" in err;
|
|
30335
31426
|
}
|
|
30336
31427
|
z.enum(["hook", "sweep"]);
|
|
31428
|
+
//#endregion
|
|
31429
|
+
//#region src/scaler/firecracker-extra-hosts.ts
|
|
31430
|
+
/**
|
|
31431
|
+
* A Firecracker scaler's `extraHosts`: the `host:address` mappings the rootfs
|
|
31432
|
+
* `/init` appends to each guest's `/etc/hosts`.
|
|
31433
|
+
*
|
|
31434
|
+
* An entry is `host:address`, the form the container backend documents for the
|
|
31435
|
+
* same field; the container runtimes accept more (`host=address`, bracketed
|
|
31436
|
+
* IPv6), a Firecracker scaler does not. `host-gateway` stands for the bridge
|
|
31437
|
+
* gateway, the address a microVM reaches its host at. A guest carries no
|
|
31438
|
+
* mapping unless the scaler configures one.
|
|
31439
|
+
*/
|
|
31440
|
+
/** The address keyword for the bridge gateway, as the container runtimes spell it. */
|
|
31441
|
+
const HOST_GATEWAY = "host-gateway";
|
|
31442
|
+
/**
|
|
31443
|
+
* An RFC 1123 hostname: dot-separated labels of letters, digits and inner
|
|
31444
|
+
* hyphens. Nothing else can reach the guest's `/etc/hosts`, so an entry can
|
|
31445
|
+
* never add a second line or a second name there.
|
|
31446
|
+
*/
|
|
31447
|
+
const HOSTNAME = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$/;
|
|
31448
|
+
/**
|
|
31449
|
+
* Parse one `host:address` entry. The host ends at the first colon, so an
|
|
31450
|
+
* IPv6 address keeps its own colons. Throws on a malformed entry.
|
|
31451
|
+
*/
|
|
31452
|
+
function parseExtraHost(entry) {
|
|
31453
|
+
const sep = entry.indexOf(":");
|
|
31454
|
+
const host = sep === -1 ? "" : entry.slice(0, sep);
|
|
31455
|
+
const address = sep === -1 ? "" : entry.slice(sep + 1);
|
|
31456
|
+
if (host === "" || address === "") throw new Error(`extraHosts entry "${entry}" must be host:address`);
|
|
31457
|
+
if (!HOSTNAME.test(host)) throw new Error(`extraHosts entry "${entry}": "${host}" is not a valid hostname`);
|
|
31458
|
+
if (address !== "host-gateway" && isIP(address) === 0) throw new Error(`extraHosts entry "${entry}": the address must be an IP address or ${HOST_GATEWAY}`);
|
|
31459
|
+
return {
|
|
31460
|
+
host,
|
|
31461
|
+
address
|
|
31462
|
+
};
|
|
31463
|
+
}
|
|
31464
|
+
/**
|
|
31465
|
+
* The MMDS value the rootfs `/init` reads: `host:address` pairs joined by
|
|
31466
|
+
* commas, with `host-gateway` replaced by `gateway`. `undefined` when the
|
|
31467
|
+
* scaler configures no mapping, so the guest gets none. Throws on a malformed
|
|
31468
|
+
* entry, and on `host-gateway` when `gateway` is not an IP address.
|
|
31469
|
+
*/
|
|
31470
|
+
function renderGuestExtraHosts(entries, gateway) {
|
|
31471
|
+
if (!entries || entries.length === 0) return void 0;
|
|
31472
|
+
return entries.map((entry) => {
|
|
31473
|
+
const { host, address } = parseExtraHost(entry);
|
|
31474
|
+
if (address !== "host-gateway") return `${host}:${address}`;
|
|
31475
|
+
if (isIP(gateway) === 0) throw new Error(`extraHosts entry "${entry}": the bridge gateway "${gateway}" is not an IP address`);
|
|
31476
|
+
return `${host}:${gateway}`;
|
|
31477
|
+
}).join(",");
|
|
31478
|
+
}
|
|
30337
31479
|
/**
|
|
30338
31480
|
* Zod schema for a single resource spec (cpus + memory).
|
|
30339
31481
|
*
|
|
@@ -30476,6 +31618,12 @@ const scalerEntrySchema = z.object({
|
|
|
30476
31618
|
"auto"
|
|
30477
31619
|
]).default("auto"),
|
|
30478
31620
|
orchestratorUrl: z.string().optional(),
|
|
31621
|
+
/**
|
|
31622
|
+
* Extra `host:address` name mappings for spawned agents. A container scaler
|
|
31623
|
+
* passes them to the runtime; a Firecracker scaler writes them into each
|
|
31624
|
+
* guest's `/etc/hosts`, with `host-gateway` resolved to the bridge gateway.
|
|
31625
|
+
* None by default.
|
|
31626
|
+
*/
|
|
30479
31627
|
extraHosts: z.array(z.string()).optional(),
|
|
30480
31628
|
networkIsolation: z.boolean().default(true),
|
|
30481
31629
|
warmPool: warmPoolSchema,
|
|
@@ -30621,6 +31769,17 @@ const scalerEntrySchema = z.object({
|
|
|
30621
31769
|
message: `Firecracker scaler "${data.name}" requires a 'gid' field`,
|
|
30622
31770
|
path: ["gid"]
|
|
30623
31771
|
});
|
|
31772
|
+
(data.extraHosts ?? []).forEach((entry, i) => {
|
|
31773
|
+
try {
|
|
31774
|
+
parseExtraHost(entry);
|
|
31775
|
+
} catch (err) {
|
|
31776
|
+
ctx.addIssue({
|
|
31777
|
+
code: z.ZodIssueCode.custom,
|
|
31778
|
+
message: `Firecracker scaler "${data.name}" extraHosts[${i}]: ${toErrorMessage(err)}`,
|
|
31779
|
+
path: ["extraHosts", i]
|
|
31780
|
+
});
|
|
31781
|
+
}
|
|
31782
|
+
});
|
|
30624
31783
|
data.labelSets.forEach((ls, i) => {
|
|
30625
31784
|
if (!ls.rootfsPath) ctx.addIssue({
|
|
30626
31785
|
code: z.ZodIssueCode.custom,
|
|
@@ -31486,19 +32645,12 @@ const POSIX_ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
|
31486
32645
|
* Interface-name pattern for per-VM TAP devices.
|
|
31487
32646
|
* Matches what {@link generateTapName} produces — `kici-` plus the LAST 8
|
|
31488
32647
|
* characters of the VM id, which for a scaler-minted id is 8 lowercase hex.
|
|
32648
|
+
* The orphan sweep considers only names of this shape, so the default bridge
|
|
32649
|
+
* (`kici-br0`) and any other operator interface outside it are never
|
|
32650
|
+
* candidates. The scaler's configured bridge is skipped by name as well, in
|
|
32651
|
+
* case an operator named it in this shape: deleting it kills every running VM.
|
|
31489
32652
|
*/
|
|
31490
32653
|
const VM_TAP_PATTERN = /^kici-[0-9a-f]{8}$/;
|
|
31491
|
-
/**
|
|
31492
|
-
* Infrastructure interfaces that share the `kici-` prefix but must NEVER be
|
|
31493
|
-
* deleted by the orphan sweep. Bridges (kici-br0/kici-br1) carry all VM
|
|
31494
|
-
* traffic; kici-m01 is the staging metadata interface. Losing any of these
|
|
31495
|
-
* kills all running VMs.
|
|
31496
|
-
*/
|
|
31497
|
-
const PROTECTED_INTERFACES = [
|
|
31498
|
-
"kici-br0",
|
|
31499
|
-
"kici-br1",
|
|
31500
|
-
"kici-m01"
|
|
31501
|
-
];
|
|
31502
32654
|
/** Convert a dotted IPv4 netmask ('255.255.255.0') to a CIDR prefix length (24). */
|
|
31503
32655
|
function netmaskToPrefix(netmask) {
|
|
31504
32656
|
return netmask.split(".").map((o) => Number(o).toString(2).padStart(8, "0")).join("").split("1").length - 1;
|
|
@@ -31533,6 +32685,8 @@ var FirecrackerScalerBackend = class FirecrackerScalerBackend {
|
|
|
31533
32685
|
bridgeName;
|
|
31534
32686
|
cidr;
|
|
31535
32687
|
gateway;
|
|
32688
|
+
/** The MMDS `kici-extra-hosts` value, or undefined when the scaler maps no host. */
|
|
32689
|
+
guestExtraHosts;
|
|
31536
32690
|
/** Host services the orchestrator directed agents at, as `hostAccess` entries. */
|
|
31537
32691
|
hostServices;
|
|
31538
32692
|
netmask;
|
|
@@ -31567,6 +32721,7 @@ var FirecrackerScalerBackend = class FirecrackerScalerBackend {
|
|
|
31567
32721
|
this.bridgeName = options.bridgeName;
|
|
31568
32722
|
this.cidr = options.cidr;
|
|
31569
32723
|
this.gateway = options.gateway;
|
|
32724
|
+
this.guestExtraHosts = renderGuestExtraHosts(options.extraHosts, options.gateway);
|
|
31570
32725
|
this.hostServices = options.hostServices;
|
|
31571
32726
|
this.netmask = options.netmask;
|
|
31572
32727
|
this.table = options.table ?? "kici";
|
|
@@ -31661,7 +32816,7 @@ var FirecrackerScalerBackend = class FirecrackerScalerBackend {
|
|
|
31661
32816
|
});
|
|
31662
32817
|
let alloc;
|
|
31663
32818
|
const onSpawnAbort = () => {
|
|
31664
|
-
this.
|
|
32819
|
+
this.abandonSpawnDetached(agentId, alloc, "spawn aborted (deadline or shutdown)");
|
|
31665
32820
|
};
|
|
31666
32821
|
signal?.addEventListener("abort", onSpawnAbort, { once: true });
|
|
31667
32822
|
try {
|
|
@@ -31800,8 +32955,8 @@ var FirecrackerScalerBackend = class FirecrackerScalerBackend {
|
|
|
31800
32955
|
"kici-agent-id": agentId,
|
|
31801
32956
|
"kici-labels": fullLabels.join(","),
|
|
31802
32957
|
"kici-scaler-managed": "1",
|
|
31803
|
-
"kici-gateway-ip": this.gateway,
|
|
31804
32958
|
...agentToken ? { "kici-agent-token": agentToken } : {},
|
|
32959
|
+
...this.guestExtraHosts ? { "kici-extra-hosts": this.guestExtraHosts } : {},
|
|
31805
32960
|
...matchedLabelSet.backpressureMode ? { "kici-backpressure-mode": matchedLabelSet.backpressureMode } : {},
|
|
31806
32961
|
...Object.keys(acceptedEnv).length > 0 ? { "kici-env": acceptedEnv } : {}
|
|
31807
32962
|
} } });
|
|
@@ -31837,6 +32992,13 @@ var FirecrackerScalerBackend = class FirecrackerScalerBackend {
|
|
|
31837
32992
|
logger$2.warn(`Cleanup after abandoned spawn ${agentId} failed: ${toErrorMessage(err)}`);
|
|
31838
32993
|
}
|
|
31839
32994
|
}
|
|
32995
|
+
/**
|
|
32996
|
+
* {@link abandonSpawn} from a path that cannot await it (an abort listener, a
|
|
32997
|
+
* process exit handler). A failure is logged.
|
|
32998
|
+
*/
|
|
32999
|
+
abandonSpawnDetached(agentId, alloc, reason) {
|
|
33000
|
+
runDetached(logger$2, "Abandoned spawn teardown", () => this.abandonSpawn(agentId, alloc, reason), { agentId });
|
|
33001
|
+
}
|
|
31840
33002
|
getScalerContext(agentId) {
|
|
31841
33003
|
const managed = this.agents.get(agentId);
|
|
31842
33004
|
if (!managed) return void 0;
|
|
@@ -32214,7 +33376,7 @@ var FirecrackerScalerBackend = class FirecrackerScalerBackend {
|
|
|
32214
33376
|
}
|
|
32215
33377
|
for (const name of ifaceNames) {
|
|
32216
33378
|
if (!VM_TAP_PATTERN.test(name)) continue;
|
|
32217
|
-
if (
|
|
33379
|
+
if (name === this.bridgeName) continue;
|
|
32218
33380
|
if (allocatedTapsBefore.has(name)) continue;
|
|
32219
33381
|
if (allocatedTapsAfter.has(name)) continue;
|
|
32220
33382
|
if (trackedTapDevices.has(name)) continue;
|
|
@@ -32273,9 +33435,9 @@ var FirecrackerScalerBackend = class FirecrackerScalerBackend {
|
|
|
32273
33435
|
/**
|
|
32274
33436
|
* Default interval for periodic orphan sweeps (15 minutes).
|
|
32275
33437
|
*
|
|
32276
|
-
* Long-running orchestrators need in-process orphan sweeps because
|
|
32277
|
-
*
|
|
32278
|
-
*
|
|
33438
|
+
* Long-running orchestrators need in-process orphan sweeps because a
|
|
33439
|
+
* host-level cleanup job skips TAP cleanup while an orchestrator is active
|
|
33440
|
+
* (to avoid racing spawns). Without this, leaked
|
|
32279
33441
|
* TAPs accumulate until the orchestrator restarts — which can be weeks.
|
|
32280
33442
|
*/
|
|
32281
33443
|
static ORPHAN_SWEEP_INTERVAL_MS = 9e5;
|
|
@@ -32672,8 +33834,8 @@ var FirecrackerScalerBackend = class FirecrackerScalerBackend {
|
|
|
32672
33834
|
this.tailAbortControllers.set(agentId, abortController);
|
|
32673
33835
|
const signal = abortController.signal;
|
|
32674
33836
|
const output = process.stdout;
|
|
32675
|
-
this.tailAndForward(serialLogPath, agentId, "firecracker-serial", signal, output);
|
|
32676
|
-
this.tailAndForward(vmmLogPath, agentId, "firecracker-vmm", signal, output);
|
|
33837
|
+
runDetached(logger$2, "Serial console tail", () => this.tailAndForward(serialLogPath, agentId, "firecracker-serial", signal, output), { agentId });
|
|
33838
|
+
runDetached(logger$2, "VMM log tail", () => this.tailAndForward(vmmLogPath, agentId, "firecracker-vmm", signal, output), { agentId });
|
|
32677
33839
|
child.on("exit", (code, sig) => {
|
|
32678
33840
|
setTimeout(() => {
|
|
32679
33841
|
abortController.abort();
|
|
@@ -32687,7 +33849,7 @@ var FirecrackerScalerBackend = class FirecrackerScalerBackend {
|
|
|
32687
33849
|
signal: sig,
|
|
32688
33850
|
failureType
|
|
32689
33851
|
}), agentId, output, void 0, "firecracker-serial");
|
|
32690
|
-
this.
|
|
33852
|
+
this.abandonSpawnDetached(agentId, alloc, `jailer exited during boot (code ${code}${sig ? `, signal ${sig}` : ""}, ${failureType})`);
|
|
32691
33853
|
}
|
|
32692
33854
|
}, 500);
|
|
32693
33855
|
});
|
|
@@ -33601,7 +34763,7 @@ function registerScalerCommands(program) {
|
|
|
33601
34763
|
process.exit(1);
|
|
33602
34764
|
}
|
|
33603
34765
|
const scalerConfig = await loadScalerConfig(scalerConfigPath, scalerConfigDir);
|
|
33604
|
-
const port = local.server?.port ?? Number(process.env.KICI_PORT ??
|
|
34766
|
+
const port = local.server?.port ?? Number(process.env.KICI_PORT ?? ORCHESTRATOR_DEFAULT_PORT);
|
|
33605
34767
|
const basePath = local.server?.basePath ?? process.env.KICI_BASE_PATH ?? "/";
|
|
33606
34768
|
if ((opts.force ? false : await isOrchestratorHealthy(port, basePath)) && !opts.force) {
|
|
33607
34769
|
const msg = "orchestrator is up and reaps orphans itself; pass --force to reap anyway";
|
|
@@ -33902,7 +35064,7 @@ Token vocabulary:
|
|
|
33902
35064
|
* where no bundler substituted the constant — the same shape `server.ts` and
|
|
33903
35065
|
* `standalone.ts` use for `ORCHESTRATOR_VERSION`.
|
|
33904
35066
|
*/
|
|
33905
|
-
const CLI_VERSION = "0.
|
|
35067
|
+
const CLI_VERSION = "0.12.0";
|
|
33906
35068
|
/**
|
|
33907
35069
|
* Build the kici-admin Commander program with every command group registered.
|
|
33908
35070
|
* Exported so the surface registry can walk the real command tree without
|