@kici-dev/orchestrator 0.11.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/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 +1 -1
- package/dist/cli/commands/context.d.ts +2 -2
- 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/runs.d.ts +2 -3
- package/dist/cli/commands/service-health.d.ts +68 -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 +1620 -551
- 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/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 +50 -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-event-log.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 +2 -2
- 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 +2736 -2211
- package/dist/stale-detector/stale-run-detector.d.ts +1 -1
- package/dist/standalone.d.ts +1 -1
- package/dist/standalone.js +2105 -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";
|
|
@@ -1025,13 +1027,13 @@ var PgSecretStore = class PgSecretStore {
|
|
|
1025
1027
|
//#endregion
|
|
1026
1028
|
//#region src/cold-store/load-access-log-range.ts
|
|
1027
1029
|
/**
|
|
1028
|
-
* Read-through helper for `access_log` (Orchestrator)
|
|
1030
|
+
* Read-through helper for `access_log` (Orchestrator).
|
|
1029
1031
|
*
|
|
1030
1032
|
* Unlike `audit_log` and `secret_audit_log`, the `access_log` callers
|
|
1031
1033
|
* (dashboard "Data access" tab, admin HTTP route, `kici-admin
|
|
1032
1034
|
* access-log list` CLI) all use the same cursor-based pagination
|
|
1033
|
-
* already implemented by `AccessLogWriter.query()`.
|
|
1034
|
-
*
|
|
1035
|
+
* already implemented by `AccessLogWriter.query()`. access_log is
|
|
1036
|
+
* "paginated/transparent" — there
|
|
1035
1037
|
* is NO `--include-archived` flag. The helper merges cold rows
|
|
1036
1038
|
* automatically when pagination crosses the warm cutoff or the caller
|
|
1037
1039
|
* supplies a `from` timestamp older than warm.
|
|
@@ -1211,9 +1213,9 @@ var init_load_access_log_range = __esmMin((() => {
|
|
|
1211
1213
|
//#endregion
|
|
1212
1214
|
//#region src/cold-store/load-secret-audit-log-range.ts
|
|
1213
1215
|
/**
|
|
1214
|
-
* Read-through helper for `secret_audit_log` (Orchestrator)
|
|
1216
|
+
* Read-through helper for `secret_audit_log` (Orchestrator).
|
|
1215
1217
|
*
|
|
1216
|
-
* Mirrors the
|
|
1218
|
+
* Mirrors the execution-table reader pattern. Wraps `AuditLogger.query()`'s
|
|
1217
1219
|
* existing offset/limit shape; cold-store rows are fetched only when
|
|
1218
1220
|
* `includeArchived === true`.
|
|
1219
1221
|
*
|
|
@@ -2306,7 +2308,7 @@ var init_schema = __esmMin((() => {
|
|
|
2306
2308
|
instanceId: z.string().default(() => randomUUID()),
|
|
2307
2309
|
mode: OrchestratorMode.default("platform"),
|
|
2308
2310
|
databaseUrl: z.string().default(""),
|
|
2309
|
-
port: z.coerce.number().default(
|
|
2311
|
+
port: z.coerce.number().default(ORCHESTRATOR_DEFAULT_PORT),
|
|
2310
2312
|
basePath: z.string().default("/"),
|
|
2311
2313
|
tlsCertPath: z.string().optional(),
|
|
2312
2314
|
scalerConfigPath: z.string().optional(),
|
|
@@ -3743,12 +3745,10 @@ var _005_cold_store_chunk_counter_exports = /* @__PURE__ */ __exportAll({
|
|
|
3743
3745
|
* `kici-admin cold-store list-chunks` CLI can report totals without
|
|
3744
3746
|
* issuing S3 LIST calls (expensive + rate-limited).
|
|
3745
3747
|
*
|
|
3746
|
-
*
|
|
3747
|
-
*
|
|
3748
|
+
* The table starts empty; the cold-store archiver
|
|
3749
|
+
* updates it transactionally with every successful chunk
|
|
3748
3750
|
* write via `INSERT ... ON CONFLICT (db, table_name, tenant_id) DO
|
|
3749
3751
|
* UPDATE SET chunk_count = chunk_count + EXCLUDED.chunk_count, ...`.
|
|
3750
|
-
*
|
|
3751
|
-
* sections 5 and 8.
|
|
3752
3752
|
*/
|
|
3753
3753
|
async function up$146(db) {
|
|
3754
3754
|
await sql`
|
|
@@ -3781,12 +3781,12 @@ var _006_runs_jobs_steps_archived_at_exports = /* @__PURE__ */ __exportAll({
|
|
|
3781
3781
|
* `execution_runs` / `execution_jobs` / `execution_steps` cold-store
|
|
3782
3782
|
* schema additions.
|
|
3783
3783
|
*
|
|
3784
|
-
*
|
|
3784
|
+
* Part of the cold-storage archival system.
|
|
3785
3785
|
*
|
|
3786
3786
|
* Each table gets `archived_at TIMESTAMPTZ NULL` and
|
|
3787
3787
|
* `archive_object_key TEXT NULL` (set inside the archive tx before the
|
|
3788
|
-
* DELETE; survivors carry NULL — same pattern
|
|
3789
|
-
*
|
|
3788
|
+
* DELETE; survivors carry NULL — the same pattern `run_events` carries
|
|
3789
|
+
* in the Platform's schema).
|
|
3790
3790
|
*
|
|
3791
3791
|
* Tenant column denormalization: the orchestrator side partitions by
|
|
3792
3792
|
* `routing_key`, but only `execution_runs` carries the column today.
|
|
@@ -3795,7 +3795,7 @@ var _006_runs_jobs_steps_archived_at_exports = /* @__PURE__ */ __exportAll({
|
|
|
3795
3795
|
* denormalize `routing_key` onto both tables and backfill from the
|
|
3796
3796
|
* existing run rows. Backfill is one-shot inside this migration. New
|
|
3797
3797
|
* inserts must populate the column going forward (enforced at the
|
|
3798
|
-
* application layer
|
|
3798
|
+
* application layer).
|
|
3799
3799
|
*
|
|
3800
3800
|
* The denormalized column is left NULLable for safety: if an insert
|
|
3801
3801
|
* site is missed in the rollout, the row still lands in PG; the
|
|
@@ -3888,13 +3888,12 @@ var _007_audit_logs_archived_at_exports = /* @__PURE__ */ __exportAll({
|
|
|
3888
3888
|
* `secret_audit_log` and `access_log` cold-store schema additions, plus
|
|
3889
3889
|
* the removal of `access_log`'s `expires_at`-based hard delete.
|
|
3890
3890
|
*
|
|
3891
|
-
*
|
|
3891
|
+
* Part of the cold-storage archival system.
|
|
3892
3892
|
*
|
|
3893
3893
|
* Both tables get the same `archived_at TIMESTAMPTZ NULL` /
|
|
3894
|
-
* `archive_object_key TEXT NULL` pair that
|
|
3895
|
-
* (`
|
|
3896
|
-
*
|
|
3897
|
-
* `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
|
|
3898
3897
|
* the archive transaction before the DELETE; survivors carry NULL.
|
|
3899
3898
|
*
|
|
3900
3899
|
* `secret_audit_log` gets a composite
|
|
@@ -3908,7 +3907,7 @@ var _007_audit_logs_archived_at_exports = /* @__PURE__ */ __exportAll({
|
|
|
3908
3907
|
* (range scans use either direction).
|
|
3909
3908
|
*
|
|
3910
3909
|
* Drops `access_log.expires_at` column and `access_log_expires_idx`.
|
|
3911
|
-
*
|
|
3910
|
+
* Before this migration, `expires_at` powered a 90-day hard-delete sweep in
|
|
3912
3911
|
* `packages/orchestrator/src/audit/access-log.ts:cleanup` (called from
|
|
3913
3912
|
* `packages/orchestrator/src/queue/cleanup.ts:runCleanup` step 5). With
|
|
3914
3913
|
* cold-store, `access_log` rows older than 30 days are archived rather
|
|
@@ -3916,11 +3915,9 @@ var _007_audit_logs_archived_at_exports = /* @__PURE__ */ __exportAll({
|
|
|
3916
3915
|
* column would mean either (a) double-evicting rows or (b) silently
|
|
3917
3916
|
* mismatching the new contract. Drop it.
|
|
3918
3917
|
*
|
|
3919
|
-
* Per the project's "no backward compatibility (pre-release)" rule,
|
|
3920
3918
|
* `down()` recreates `expires_at` with a default of `now() + 90 days`
|
|
3921
3919
|
* — best effort; rows inserted between `up()` and a hypothetical
|
|
3922
|
-
* `down()` would not have meaningful retention bounds.
|
|
3923
|
-
* staging.
|
|
3920
|
+
* `down()` would not have meaningful retention bounds.
|
|
3924
3921
|
*/
|
|
3925
3922
|
async function up$144(db) {
|
|
3926
3923
|
await sql`
|
|
@@ -3978,13 +3975,11 @@ var _008_event_log_archived_at_exports = /* @__PURE__ */ __exportAll({
|
|
|
3978
3975
|
* `event_log` cold-store schema additions plus removal of the
|
|
3979
3976
|
* `expires_at`-based 30-day hard delete.
|
|
3980
3977
|
*
|
|
3981
|
-
*
|
|
3982
|
-
* §10
|
|
3983
|
-
* Phase E.
|
|
3978
|
+
* Part of the cold-storage archival system.
|
|
3984
3979
|
*
|
|
3985
3980
|
* Adds the same `archived_at TIMESTAMPTZ NULL` /
|
|
3986
|
-
* `archive_object_key TEXT NULL` pair
|
|
3987
|
-
*
|
|
3981
|
+
* `archive_object_key TEXT NULL` pair
|
|
3982
|
+
* `007_audit_logs_archived_at.ts` added on this side. Set inside the archive
|
|
3988
3983
|
* transaction before the DELETE; survivors carry NULL.
|
|
3989
3984
|
*
|
|
3990
3985
|
* Adds `idx_event_log_routing_received` on `(routing_key, received_at)`
|
|
@@ -3995,7 +3990,7 @@ var _008_event_log_archived_at_exports = /* @__PURE__ */ __exportAll({
|
|
|
3995
3990
|
* archiver scan.
|
|
3996
3991
|
*
|
|
3997
3992
|
* Drops `event_log.expires_at` and the matching
|
|
3998
|
-
* `event_log_expires_at_idx` index.
|
|
3993
|
+
* `event_log_expires_at_idx` index. Before this migration, `expires_at`
|
|
3999
3994
|
* powered the 30-day hard-delete sweep in
|
|
4000
3995
|
* `packages/orchestrator/src/webhook/event-log.ts:cleanup`, called
|
|
4001
3996
|
* from `packages/orchestrator/src/queue/cleanup.ts:runCleanup` step 4
|
|
@@ -4078,7 +4073,7 @@ var _010_cold_store_chunks_exports = /* @__PURE__ */ __exportAll({
|
|
|
4078
4073
|
up: () => up$141
|
|
4079
4074
|
});
|
|
4080
4075
|
/**
|
|
4081
|
-
* Cold-store chunk index
|
|
4076
|
+
* Cold-store chunk index (cold-store purge).
|
|
4082
4077
|
*
|
|
4083
4078
|
* One row per archived chunk that the GC sweep can later purge from S3.
|
|
4084
4079
|
* Inserted inside the same transaction as `markArchivedAndDelete` so a
|
|
@@ -4103,7 +4098,7 @@ var _010_cold_store_chunks_exports = /* @__PURE__ */ __exportAll({
|
|
|
4103
4098
|
* uses this to issue `DeleteObject` directly without recomputing the
|
|
4104
4099
|
* key from the prefix.
|
|
4105
4100
|
*
|
|
4106
|
-
*
|
|
4101
|
+
* V1-manifest chunks are NOT in this table — they live
|
|
4107
4102
|
* forever. Adapters that don't opt into per-bucket archival via
|
|
4108
4103
|
* `coldTtlDays` don't insert here either.
|
|
4109
4104
|
*/
|
|
@@ -4219,13 +4214,11 @@ var _012_peer_credentials_active_uniq_exports = /* @__PURE__ */ __exportAll({
|
|
|
4219
4214
|
* nothing and both `INSERT`s succeeded, leaving 2+ ACTIVE rows for the same
|
|
4220
4215
|
* `instance_id`. The peer's local credential file then ended up holding a
|
|
4221
4216
|
* hash that a later concurrent `save()` had revoked, causing an endless
|
|
4222
|
-
* "Peer HMAC proof invalid" loop.
|
|
4223
|
-
* for the
|
|
4224
|
-
* full incident write-up (DB rows + log evidence from real staging deploys).
|
|
4217
|
+
* "Peer HMAC proof invalid" loop.
|
|
4225
4218
|
*
|
|
4226
4219
|
* The `up()` first dedupes any pre-existing duplicates (revoke all but the
|
|
4227
4220
|
* newest active row per `instance_id`) — required because earlier migrations
|
|
4228
|
-
* predate the constraint and existing
|
|
4221
|
+
* predate the constraint and existing databases may already hold the dupes.
|
|
4229
4222
|
*
|
|
4230
4223
|
* `down()` only drops the index; it does NOT undo the dedupe (there's no
|
|
4231
4224
|
* safe way to recreate revoked rows, and the dedupe is monotonic).
|
|
@@ -4563,8 +4556,8 @@ var _016_org_settings_allow_http_npm_exports = /* @__PURE__ */ __exportAll({
|
|
|
4563
4556
|
* Operators flip this on a per-org basis to permit `http://` (non-HTTPS)
|
|
4564
4557
|
* registry URLs in workflow `registries:` declarations. By default only
|
|
4565
4558
|
* `https://` registries are accepted, with an automatic exemption for
|
|
4566
|
-
* loopback / `*.local` / private link-local hosts so
|
|
4567
|
-
* (
|
|
4559
|
+
* loopback / `*.local` / private link-local hosts so a local registry
|
|
4560
|
+
* (for example `http://registry.local:4873`) keeps working.
|
|
4568
4561
|
*
|
|
4569
4562
|
* Idempotent: a re-run on a DB that already has the column is a no-op.
|
|
4570
4563
|
*/
|
|
@@ -4609,8 +4602,7 @@ var _017_org_id_widen_exports = /* @__PURE__ */ __exportAll({
|
|
|
4609
4602
|
* They are orphans by design — once Platform's migration runs, the
|
|
4610
4603
|
* dashboard queries the orchestrator API with the new prefixed IDs
|
|
4611
4604
|
* and the orchestrator's `WHERE org_id = $1` filter naturally hides
|
|
4612
|
-
* the old rows.
|
|
4613
|
-
* test run.
|
|
4605
|
+
* the old rows. New activity writes fresh rows under the prefixed IDs.
|
|
4614
4606
|
*
|
|
4615
4607
|
* Tables widened: `access_log`, `environment_bindings`,
|
|
4616
4608
|
* `environment_source_overrides`, `environment_variables`,
|
|
@@ -4652,7 +4644,7 @@ var _018_org_id_prefix_backfill_exports = /* @__PURE__ */ __exportAll({
|
|
|
4652
4644
|
* orphaned and regenerated on the next deploy. That works for
|
|
4653
4645
|
* historical artefacts (execution_runs, event_log, access_log) but
|
|
4654
4646
|
* NOT for tenant-state tables the orchestrator needs to recognise the
|
|
4655
|
-
*
|
|
4647
|
+
* tenant's org at routing time (`sources`, `generic_webhook_sources`,
|
|
4656
4648
|
* `workflow_registrations`, `org_settings`) and the env-config tables
|
|
4657
4649
|
* referenced from runtime requests (`environments`,
|
|
4658
4650
|
* `environment_bindings`, `environment_source_overrides`,
|
|
@@ -5907,7 +5899,7 @@ var _045_host_properties_exports = /* @__PURE__ */ __exportAll({
|
|
|
5907
5899
|
* the separate queryable host-vars dimension.
|
|
5908
5900
|
*
|
|
5909
5901
|
* Idempotent (`ADD COLUMN IF NOT EXISTS`): re-running on a DB that already has
|
|
5910
|
-
* the column is a no-op.
|
|
5902
|
+
* the column is a no-op. Existing data is preserved (additive column).
|
|
5911
5903
|
*/
|
|
5912
5904
|
async function up$106(db) {
|
|
5913
5905
|
await sql`ALTER TABLE public.host_roster
|
|
@@ -5933,7 +5925,7 @@ var _046_join_token_consumed_by_instance_exports = /* @__PURE__ */ __exportAll({
|
|
|
5933
5925
|
* join token already in its env — no operator action, no cluster redeploy.
|
|
5934
5926
|
*
|
|
5935
5927
|
* Idempotent (`ADD COLUMN IF NOT EXISTS`): re-running on a DB that already has
|
|
5936
|
-
* the column is a no-op.
|
|
5928
|
+
* the column is a no-op. Existing data is preserved (additive nullable column).
|
|
5937
5929
|
*/
|
|
5938
5930
|
async function up$105(db) {
|
|
5939
5931
|
await sql`ALTER TABLE public.join_tokens
|
|
@@ -5959,8 +5951,7 @@ var _047_needs_run_on_exports = /* @__PURE__ */ __exportAll({
|
|
|
5959
5951
|
* - Drop `if_failed`.
|
|
5960
5952
|
*
|
|
5961
5953
|
* Idempotent: column-exists guarded so re-running on a DB that already has
|
|
5962
|
-
* `run_on` (and no `if_failed`) is a no-op. Existing rows are preserved
|
|
5963
|
-
* (staging data is not dropped).
|
|
5954
|
+
* `run_on` (and no `if_failed`) is a no-op. Existing rows are preserved.
|
|
5964
5955
|
*/
|
|
5965
5956
|
const ALL_TERMINAL_JSON = JSON.stringify([
|
|
5966
5957
|
"success",
|
|
@@ -6032,7 +6023,7 @@ var _048_host_reboot_pending_exports = /* @__PURE__ */ __exportAll({
|
|
|
6032
6023
|
* (3) clears on the next reconnect (down-then-up), releasing the held job. NULL
|
|
6033
6024
|
* = no reboot pending.
|
|
6034
6025
|
*
|
|
6035
|
-
* Idempotent (`ADD COLUMN IF NOT EXISTS`); additive, so
|
|
6026
|
+
* Idempotent (`ADD COLUMN IF NOT EXISTS`); additive, so existing data is
|
|
6036
6027
|
* preserved.
|
|
6037
6028
|
*/
|
|
6038
6029
|
async function up$103(db) {
|
|
@@ -6055,7 +6046,7 @@ var _049_held_runs_payload_exports = /* @__PURE__ */ __exportAll({
|
|
|
6055
6046
|
* the dashboard approval queue and the CLI render the computed diff the
|
|
6056
6047
|
* operator approves. NULL for every non-drift hold.
|
|
6057
6048
|
*
|
|
6058
|
-
* Idempotent (`ADD COLUMN IF NOT EXISTS`); additive, so
|
|
6049
|
+
* Idempotent (`ADD COLUMN IF NOT EXISTS`); additive, so existing data is
|
|
6059
6050
|
* preserved.
|
|
6060
6051
|
*/
|
|
6061
6052
|
async function up$102(db) {
|
|
@@ -6081,7 +6072,7 @@ var _050_sources_slug_exports = /* @__PURE__ */ __exportAll({
|
|
|
6081
6072
|
* hasn't populated it yet (manual `--app-id` flow whose initial fetch failed,
|
|
6082
6073
|
* or a row created before the rollout).
|
|
6083
6074
|
*
|
|
6084
|
-
* Idempotent (`ADD COLUMN IF NOT EXISTS`); additive, so
|
|
6075
|
+
* Idempotent (`ADD COLUMN IF NOT EXISTS`); additive, so existing data is
|
|
6085
6076
|
* preserved.
|
|
6086
6077
|
*/
|
|
6087
6078
|
async function up$101(db) {
|
|
@@ -6109,7 +6100,7 @@ var _051_binding_host_pattern_exports = /* @__PURE__ */ __exportAll({
|
|
|
6109
6100
|
* distinct per-host selectors.
|
|
6110
6101
|
*
|
|
6111
6102
|
* Idempotent: re-running on a DB that already has the column / index is a no-op.
|
|
6112
|
-
* Additive —
|
|
6103
|
+
* Additive — existing data is preserved.
|
|
6113
6104
|
*/
|
|
6114
6105
|
async function up$100(db) {
|
|
6115
6106
|
await sql`ALTER TABLE public.environment_bindings
|
|
@@ -6145,7 +6136,7 @@ var _052_host_reach_metadata_exports = /* @__PURE__ */ __exportAll({
|
|
|
6145
6136
|
*
|
|
6146
6137
|
* All nullable: a host with no reach metadata simply cannot be bootstrapped and
|
|
6147
6138
|
* behaves exactly as before. Idempotent (`ADD COLUMN IF NOT EXISTS`); additive,
|
|
6148
|
-
* so
|
|
6139
|
+
* so existing data is preserved.
|
|
6149
6140
|
*/
|
|
6150
6141
|
async function up$99(db) {
|
|
6151
6142
|
await sql`ALTER TABLE public.host_roster
|
|
@@ -6177,7 +6168,7 @@ var _053_agent_token_single_use_exports = /* @__PURE__ */ __exportAll({
|
|
|
6177
6168
|
* default for every existing static / ephemeral token, which stay reusable
|
|
6178
6169
|
* until expiry).
|
|
6179
6170
|
*
|
|
6180
|
-
* Idempotent (`ADD COLUMN IF NOT EXISTS`); additive, so
|
|
6171
|
+
* Idempotent (`ADD COLUMN IF NOT EXISTS`); additive, so existing data is
|
|
6181
6172
|
* preserved.
|
|
6182
6173
|
*/
|
|
6183
6174
|
async function up$98(db) {
|
|
@@ -6202,7 +6193,7 @@ var _054_local_working_tree_exports = /* @__PURE__ */ __exportAll({
|
|
|
6202
6193
|
* lock file). The dashboard renders a "Local machine" badge for these runs
|
|
6203
6194
|
* and avoids building an external repository link from the repo identifier.
|
|
6204
6195
|
*
|
|
6205
|
-
* Idempotent (`ADD COLUMN IF NOT EXISTS`); additive with a default, so
|
|
6196
|
+
* Idempotent (`ADD COLUMN IF NOT EXISTS`); additive with a default, so existing
|
|
6206
6197
|
* data is preserved.
|
|
6207
6198
|
*/
|
|
6208
6199
|
async function up$97(db) {
|
|
@@ -6230,7 +6221,7 @@ var _055_agent_token_mandatory_labels_exports = /* @__PURE__ */ __exportAll({
|
|
|
6230
6221
|
* agent accepts any job its advertised labels match), so every existing token
|
|
6231
6222
|
* stays unconfined until re-minted.
|
|
6232
6223
|
*
|
|
6233
|
-
* Idempotent (`ADD COLUMN IF NOT EXISTS`); additive, so
|
|
6224
|
+
* Idempotent (`ADD COLUMN IF NOT EXISTS`); additive, so existing data is
|
|
6234
6225
|
* preserved.
|
|
6235
6226
|
*/
|
|
6236
6227
|
async function up$96(db) {
|
|
@@ -6257,7 +6248,7 @@ var _056_execution_jobs_environments_exports = /* @__PURE__ */ __exportAll({
|
|
|
6257
6248
|
* fully-resolved list when a deferred-init agent eval resolves dynamic
|
|
6258
6249
|
* elements. NULL = the job binds no environment.
|
|
6259
6250
|
*
|
|
6260
|
-
* Idempotent (`ADD COLUMN IF NOT EXISTS`); additive, so
|
|
6251
|
+
* Idempotent (`ADD COLUMN IF NOT EXISTS`); additive, so existing data is
|
|
6261
6252
|
* preserved.
|
|
6262
6253
|
*/
|
|
6263
6254
|
async function up$95(db) {
|
|
@@ -6568,7 +6559,7 @@ var _064_execution_jobs_skipped_environments_exports = /* @__PURE__ */ __exportA
|
|
|
6568
6559
|
* - `execution_jobs.env_warning text NULL` — the user-visible warning naming the
|
|
6569
6560
|
* skipped environments, surfaced on the dashboard run view. NULL = no warning.
|
|
6570
6561
|
*
|
|
6571
|
-
* Idempotent (`ADD COLUMN IF NOT EXISTS`); additive, so
|
|
6562
|
+
* Idempotent (`ADD COLUMN IF NOT EXISTS`); additive, so existing data is
|
|
6572
6563
|
* preserved.
|
|
6573
6564
|
*/
|
|
6574
6565
|
async function up$87(db) {
|
|
@@ -10951,11 +10942,10 @@ var AccessLogWriter = class {
|
|
|
10951
10942
|
}).execute();
|
|
10952
10943
|
}
|
|
10953
10944
|
/**
|
|
10954
|
-
*
|
|
10945
|
+
* Delegates to `loadAccessLogRange` so cold-store rows are
|
|
10955
10946
|
* merged transparently when pagination crosses the warm cutoff. The
|
|
10956
10947
|
* caller-visible `{items, nextCursor}` shape is preserved. NO
|
|
10957
|
-
* --include-archived flag — access_log is "paginated/transparent"
|
|
10958
|
-
* per design §7 row 11.
|
|
10948
|
+
* --include-archived flag — access_log is "paginated/transparent".
|
|
10959
10949
|
*/
|
|
10960
10950
|
async query(filter) {
|
|
10961
10951
|
const limit = Math.max(1, Math.min(filter.limit ?? 50, 200));
|
|
@@ -11758,7 +11748,7 @@ function uninstallBackupTimer(platform, config, io = defaultTimerIo) {
|
|
|
11758
11748
|
}
|
|
11759
11749
|
//#endregion
|
|
11760
11750
|
//#region src/cli/service/types.ts
|
|
11761
|
-
var DEFAULT_RESTART_POLICY;
|
|
11751
|
+
var DEFAULT_RESTART_POLICY, SERVICE_TEXT;
|
|
11762
11752
|
var init_types$1 = __esmMin((() => {
|
|
11763
11753
|
DEFAULT_RESTART_POLICY = {
|
|
11764
11754
|
enabled: true,
|
|
@@ -11771,6 +11761,16 @@ var init_types$1 = __esmMin((() => {
|
|
|
11771
11761
|
maxRetries: 5,
|
|
11772
11762
|
windowSeconds: 300
|
|
11773
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
|
+
};
|
|
11774
11774
|
}));
|
|
11775
11775
|
//#endregion
|
|
11776
11776
|
//#region src/cli/service/privilege.ts
|
|
@@ -11847,6 +11847,148 @@ var init_privilege = __esmMin((() => {
|
|
|
11847
11847
|
init_platform_detect();
|
|
11848
11848
|
}));
|
|
11849
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
|
|
11850
11992
|
//#region src/cli/service/instance/types.ts
|
|
11851
11993
|
/** Narrow an arbitrary string to a Component. */
|
|
11852
11994
|
function isComponent(value) {
|
|
@@ -11857,6 +11999,81 @@ var init_types = __esmMin((() => {
|
|
|
11857
11999
|
COMPONENTS = ["orchestrator", "agent"];
|
|
11858
12000
|
}));
|
|
11859
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
|
|
11860
12077
|
//#region src/cli/service/instance/manifest.ts
|
|
11861
12078
|
/**
|
|
11862
12079
|
* Instance manifest — the single source of truth for a folder-anchored
|
|
@@ -11947,6 +12164,90 @@ function resolveVersionFromLaunchSpec(spec, component) {
|
|
|
11947
12164
|
}
|
|
11948
12165
|
return null;
|
|
11949
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
|
+
}
|
|
11950
12251
|
function resolveNpmInstallTarget(spec, component, opts) {
|
|
11951
12252
|
const entryRe = new RegExp(`[/\\\\]@kici-dev[/\\\\]${component}[/\\\\]dist[/\\\\](?:server|standalone)\\.js$`);
|
|
11952
12253
|
const entry = [spec.execPath, ...spec.args].find((a) => entryRe.test(a));
|
|
@@ -11982,6 +12283,7 @@ function firstGlobalPackage(entry) {
|
|
|
11982
12283
|
}
|
|
11983
12284
|
var REQUIRED_FIELDS;
|
|
11984
12285
|
var init_manifest = __esmMin((() => {
|
|
12286
|
+
init_image_digests();
|
|
11985
12287
|
REQUIRED_FIELDS = [
|
|
11986
12288
|
"component",
|
|
11987
12289
|
"name",
|
|
@@ -12549,6 +12851,43 @@ var init_systemd = __esmMin((() => {
|
|
|
12549
12851
|
};
|
|
12550
12852
|
}));
|
|
12551
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
|
|
12552
12891
|
//#region src/cli/service/launchd.ts
|
|
12553
12892
|
/**
|
|
12554
12893
|
* launchd service manager implementation.
|
|
@@ -12571,14 +12910,19 @@ function stripLabelPrefix(label) {
|
|
|
12571
12910
|
return label;
|
|
12572
12911
|
}
|
|
12573
12912
|
/** Async sleep used to pace launchd bootout/bootstrap reconciliation. */
|
|
12574
|
-
function sleep(ms) {
|
|
12913
|
+
function sleep$1(ms) {
|
|
12575
12914
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
12576
12915
|
}
|
|
12577
|
-
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;
|
|
12578
12917
|
var init_launchd = __esmMin((() => {
|
|
12918
|
+
init_shutdown_grace();
|
|
12579
12919
|
LABEL_PREFIX = "dev.kici";
|
|
12580
12920
|
KNOWN_LABEL_PREFIXES = ["dev.kici.", "com.kici."];
|
|
12581
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;
|
|
12582
12926
|
LaunchdServiceManager = class {
|
|
12583
12927
|
platform = "launchd";
|
|
12584
12928
|
/** Build the launchd label for a service. */
|
|
@@ -12590,11 +12934,51 @@ var init_launchd = __esmMin((() => {
|
|
|
12590
12934
|
if (config.isUserLevel) return path.join(os.homedir(), "Library", "Logs", "kici");
|
|
12591
12935
|
return SYSTEM_LOG_DIR;
|
|
12592
12936
|
}
|
|
12593
|
-
/**
|
|
12594
|
-
|
|
12595
|
-
|
|
12596
|
-
|
|
12597
|
-
|
|
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;
|
|
12598
12982
|
}
|
|
12599
12983
|
/** Parse a .env file into key-value pairs. */
|
|
12600
12984
|
parseEnvFile(envFilePath) {
|
|
@@ -12650,11 +13034,18 @@ var init_launchd = __esmMin((() => {
|
|
|
12650
13034
|
lines.push(` <string>${this.escapeXml(config.workingDirectory)}</string>`);
|
|
12651
13035
|
lines.push(" <key>RunAtLoad</key>");
|
|
12652
13036
|
lines.push(" <true/>");
|
|
12653
|
-
lines.push(" <key>
|
|
12654
|
-
lines.push(
|
|
12655
|
-
if (config.restartPolicy.enabled
|
|
12656
|
-
lines.push(" <key>
|
|
12657
|
-
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
|
+
}
|
|
12658
13049
|
}
|
|
12659
13050
|
lines.push(" <key>StandardOutPath</key>");
|
|
12660
13051
|
lines.push(` <string>${this.escapeXml(path.join(logDirectory, `${config.name}.out.log`))}</string>`);
|
|
@@ -12690,30 +13081,48 @@ var init_launchd = __esmMin((() => {
|
|
|
12690
13081
|
* domain from the plist's filesystem location, but they fail silently on
|
|
12691
13082
|
* headless macOS hosts where no GUI session exists (the `gui/<uid>` domain
|
|
12692
13083
|
* is not available without a console login). The modern `bootstrap` /
|
|
12693
|
-
* `bootout` / `kickstart` / `
|
|
13084
|
+
* `bootout` / `kickstart` / `print` verbs take the domain explicitly, so a
|
|
12694
13085
|
* system-level LaunchDaemon loads regardless of whether anyone is logged
|
|
12695
13086
|
* in at the console — exactly what we need for headless deploy targets.
|
|
12696
13087
|
*/
|
|
12697
13088
|
domain(config) {
|
|
12698
13089
|
return config.isUserLevel ? `gui/${os.userInfo().uid}` : "system";
|
|
12699
13090
|
}
|
|
12700
|
-
/** `<domain>/<label>` — the target string for kickstart/
|
|
12701
|
-
domainTarget(config) {
|
|
12702
|
-
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}`;
|
|
12703
13094
|
}
|
|
12704
|
-
/**
|
|
12705
|
-
|
|
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) {
|
|
12706
13103
|
try {
|
|
12707
|
-
execFileSync("launchctl", ["print", this.domainTarget(config)], {
|
|
12708
|
-
|
|
12709
|
-
|
|
12710
|
-
|
|
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;
|
|
12711
13116
|
}
|
|
12712
13117
|
}
|
|
13118
|
+
/** Is the job currently loaded in its target domain? */
|
|
13119
|
+
isLoaded(config, job) {
|
|
13120
|
+
return this.printJob(config, job) !== null;
|
|
13121
|
+
}
|
|
12713
13122
|
async install(config) {
|
|
12714
|
-
const
|
|
13123
|
+
const job = this.installJob(config);
|
|
12715
13124
|
const plistContent = this.generatePlist(config);
|
|
12716
|
-
fs.mkdirSync(path.dirname(
|
|
13125
|
+
fs.mkdirSync(path.dirname(job.plistPath), { recursive: true });
|
|
12717
13126
|
const logDirectory = this.logDir(config);
|
|
12718
13127
|
fs.mkdirSync(logDirectory, { recursive: true });
|
|
12719
13128
|
if (!config.isUserLevel && config.user) execFileSync("chown", [
|
|
@@ -12721,44 +13130,51 @@ var init_launchd = __esmMin((() => {
|
|
|
12721
13130
|
`${config.user}:staff`,
|
|
12722
13131
|
logDirectory
|
|
12723
13132
|
], { stdio: "inherit" });
|
|
12724
|
-
|
|
12725
|
-
if (
|
|
12726
|
-
|
|
12727
|
-
|
|
12728
|
-
} catch {}
|
|
12729
|
-
await this.waitUntilUnloaded(config);
|
|
13133
|
+
const otherJob = this.otherLabelJob(config);
|
|
13134
|
+
if (otherJob) {
|
|
13135
|
+
await this.unload(config, otherJob);
|
|
13136
|
+
fs.unlinkSync(otherJob.plistPath);
|
|
12730
13137
|
}
|
|
12731
|
-
|
|
13138
|
+
fs.writeFileSync(job.plistPath, plistContent, "utf-8");
|
|
13139
|
+
await this.unload(config, job);
|
|
13140
|
+
await this.bootstrapWithRetry(config, job, { replaceLoaded: true });
|
|
12732
13141
|
}
|
|
12733
13142
|
/**
|
|
12734
|
-
* Poll until the
|
|
12735
|
-
*
|
|
12736
|
-
*
|
|
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
|
|
12737
13147
|
* immediately afterward races the teardown and fails with EIO. Waiting for
|
|
12738
13148
|
* the unload to complete closes that race for the common case; the residual
|
|
12739
13149
|
* window is covered by bootstrapWithRetry.
|
|
12740
13150
|
*/
|
|
12741
|
-
async waitUntilUnloaded(config) {
|
|
12742
|
-
const deadline = Date.now() +
|
|
12743
|
-
while (this.isLoaded(config)) {
|
|
12744
|
-
if (Date.now() >= deadline) return;
|
|
12745
|
-
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);
|
|
12746
13156
|
}
|
|
13157
|
+
return true;
|
|
12747
13158
|
}
|
|
12748
13159
|
/**
|
|
12749
13160
|
* Bootstrap into the target domain, retrying on the transient EIO
|
|
12750
13161
|
* ("5: Input/output error") launchd returns when a just-removed service has
|
|
12751
13162
|
* not finished tearing down. A genuine, non-transient failure (bad plist,
|
|
12752
|
-
* permission denied) is re-thrown on the first attempt.
|
|
12753
|
-
*
|
|
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.
|
|
12754
13170
|
*/
|
|
12755
|
-
async bootstrapWithRetry(config,
|
|
13171
|
+
async bootstrapWithRetry(config, job, opts) {
|
|
12756
13172
|
const attempts = 5;
|
|
12757
13173
|
for (let attempt = 1; attempt <= attempts; attempt++) try {
|
|
12758
13174
|
execFileSync("launchctl", [
|
|
12759
13175
|
"bootstrap",
|
|
12760
13176
|
this.domain(config),
|
|
12761
|
-
|
|
13177
|
+
job.plistPath
|
|
12762
13178
|
], { stdio: [
|
|
12763
13179
|
"inherit",
|
|
12764
13180
|
"inherit",
|
|
@@ -12766,66 +13182,92 @@ var init_launchd = __esmMin((() => {
|
|
|
12766
13182
|
] });
|
|
12767
13183
|
return;
|
|
12768
13184
|
} catch (err) {
|
|
13185
|
+
if (!opts.replaceLoaded && this.isLoaded(config, job)) return;
|
|
12769
13186
|
const e = err;
|
|
12770
13187
|
const stderr = (e.stderr ?? "").toString();
|
|
12771
13188
|
if (!(e.status === 5 || /input\/output error|resource busy/i.test(stderr)) || attempt === attempts) {
|
|
12772
13189
|
if (stderr) process.stderr.write(stderr);
|
|
12773
13190
|
throw err;
|
|
12774
13191
|
}
|
|
12775
|
-
if (this.isLoaded(config)) try {
|
|
12776
|
-
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" });
|
|
12777
13194
|
} catch {}
|
|
12778
|
-
await sleep(1e3 * attempt);
|
|
13195
|
+
await sleep$1(1e3 * attempt);
|
|
12779
13196
|
}
|
|
12780
13197
|
}
|
|
12781
13198
|
async uninstall(config) {
|
|
12782
|
-
const
|
|
13199
|
+
const job = this.resolveJob(config);
|
|
12783
13200
|
try {
|
|
12784
|
-
execFileSync("launchctl", ["bootout", this.domainTarget(config)], { stdio:
|
|
13201
|
+
execFileSync("launchctl", ["bootout", this.domainTarget(config, job)], { stdio: [
|
|
13202
|
+
"inherit",
|
|
13203
|
+
"inherit",
|
|
13204
|
+
"pipe"
|
|
13205
|
+
] });
|
|
12785
13206
|
} catch {}
|
|
12786
13207
|
try {
|
|
12787
|
-
fs.unlinkSync(
|
|
13208
|
+
fs.unlinkSync(job.plistPath);
|
|
12788
13209
|
} catch {}
|
|
12789
13210
|
}
|
|
12790
13211
|
async start(config) {
|
|
12791
|
-
|
|
12792
|
-
|
|
12793
|
-
|
|
12794
|
-
|
|
12795
|
-
|
|
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" });
|
|
12796
13218
|
}
|
|
12797
13219
|
async stop(config) {
|
|
12798
|
-
|
|
12799
|
-
|
|
12800
|
-
|
|
12801
|
-
|
|
12802
|
-
|
|
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)}\`.`);
|
|
12803
13248
|
}
|
|
12804
13249
|
async restart(config) {
|
|
12805
13250
|
await this.stop(config);
|
|
12806
13251
|
await this.start(config);
|
|
12807
13252
|
}
|
|
12808
13253
|
async status(config) {
|
|
12809
|
-
const
|
|
13254
|
+
const job = this.resolveJob(config);
|
|
13255
|
+
let description;
|
|
12810
13256
|
try {
|
|
12811
|
-
|
|
12812
|
-
|
|
12813
|
-
|
|
12814
|
-
|
|
12815
|
-
|
|
12816
|
-
|
|
12817
|
-
|
|
12818
|
-
|
|
12819
|
-
|
|
12820
|
-
|
|
12821
|
-
|
|
12822
|
-
|
|
12823
|
-
|
|
12824
|
-
|
|
12825
|
-
}
|
|
12826
|
-
}
|
|
12827
|
-
} catch {}
|
|
12828
|
-
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" };
|
|
12829
13271
|
}
|
|
12830
13272
|
async logs(config, options) {
|
|
12831
13273
|
const logDirectory = this.logDir(config);
|
|
@@ -12843,10 +13285,10 @@ var init_launchd = __esmMin((() => {
|
|
|
12843
13285
|
});
|
|
12844
13286
|
}
|
|
12845
13287
|
async isInstalled(config) {
|
|
12846
|
-
return fs.existsSync(this.
|
|
13288
|
+
return fs.existsSync(this.resolveJob(config).plistPath);
|
|
12847
13289
|
}
|
|
12848
13290
|
async list(isUserLevel) {
|
|
12849
|
-
const baseDir =
|
|
13291
|
+
const baseDir = this.plistDir(isUserLevel);
|
|
12850
13292
|
if (!fs.existsSync(baseDir)) return [];
|
|
12851
13293
|
const out = [];
|
|
12852
13294
|
for (const entry of fs.readdirSync(baseDir)) {
|
|
@@ -12859,7 +13301,7 @@ var init_launchd = __esmMin((() => {
|
|
|
12859
13301
|
if (err?.code === "ENOENT") continue;
|
|
12860
13302
|
throw new Error(`could not read the launchd job at ${path.join(baseDir, entry)}: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
|
|
12861
13303
|
}
|
|
12862
|
-
const match = content.match(
|
|
13304
|
+
const match = content.match(KICI_COMPONENT_MARKER);
|
|
12863
13305
|
if (!match) continue;
|
|
12864
13306
|
const dirMatch = content.match(/<key>KiCIInstanceDir<\/key>\s*<string>([^<]+)<\/string>/);
|
|
12865
13307
|
out.push({
|
|
@@ -12875,7 +13317,7 @@ var init_launchd = __esmMin((() => {
|
|
|
12875
13317
|
async readLaunchSpec(config) {
|
|
12876
13318
|
let content;
|
|
12877
13319
|
try {
|
|
12878
|
-
content = fs.readFileSync(this.
|
|
13320
|
+
content = fs.readFileSync(this.resolveJob(config).plistPath, "utf-8");
|
|
12879
13321
|
} catch {
|
|
12880
13322
|
return null;
|
|
12881
13323
|
}
|
|
@@ -13144,12 +13586,75 @@ function parseStateCode(output) {
|
|
|
13144
13586
|
const match = output.match(/STATE\s*:\s*(\d+)/);
|
|
13145
13587
|
return match ? parseInt(match[1], 10) : void 0;
|
|
13146
13588
|
}
|
|
13147
|
-
|
|
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;
|
|
13148
13640
|
var init_windows = __esmMin((() => {
|
|
13149
13641
|
init_downloader();
|
|
13150
13642
|
init_registry();
|
|
13151
13643
|
init_platform_detect();
|
|
13644
|
+
init_shutdown_grace();
|
|
13645
|
+
init_windows_acl();
|
|
13646
|
+
init_windows_env_file();
|
|
13152
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";
|
|
13153
13658
|
WindowsServiceManager = class {
|
|
13154
13659
|
platform = "windows";
|
|
13155
13660
|
/**
|
|
@@ -13191,13 +13696,24 @@ var init_windows = __esmMin((() => {
|
|
|
13191
13696
|
return false;
|
|
13192
13697
|
}
|
|
13193
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
|
+
}
|
|
13194
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);
|
|
13195
13713
|
if (this.serviceExists(config.name)) await this.uninstall(config);
|
|
13196
13714
|
const depMeta = getDepMetadata("shawl");
|
|
13197
13715
|
const depPath = await ensureDep(depMeta, getCacheDir());
|
|
13198
13716
|
const shawlExe = path.join(depPath, depMeta.extractPath);
|
|
13199
|
-
const envDir = path.dirname(config.envFilePath);
|
|
13200
|
-
fs.mkdirSync(envDir, { recursive: true });
|
|
13201
13717
|
const cwd = config.workingDirectory.replace(/\\+$/, "");
|
|
13202
13718
|
const logDir = path.join(envDir, "logs");
|
|
13203
13719
|
fs.mkdirSync(logDir, { recursive: true });
|
|
@@ -13209,23 +13725,12 @@ var init_windows = __esmMin((() => {
|
|
|
13209
13725
|
"--cwd",
|
|
13210
13726
|
`"${cwd}"`,
|
|
13211
13727
|
"--log-dir",
|
|
13212
|
-
`"${logDir.replace(/\\+$/, "")}"
|
|
13728
|
+
`"${logDir.replace(/\\+$/, "")}"`,
|
|
13729
|
+
"--stop-timeout",
|
|
13730
|
+
String(shutdownGraceSeconds(config) * 1e3)
|
|
13213
13731
|
];
|
|
13214
|
-
|
|
13215
|
-
|
|
13216
|
-
for (const line of envContent.split("\n")) {
|
|
13217
|
-
const trimmed = line.trim();
|
|
13218
|
-
if (trimmed && !trimmed.startsWith("#") && trimmed.includes("=")) {
|
|
13219
|
-
const eqIdx = trimmed.indexOf("=");
|
|
13220
|
-
if (trimmed.slice(0, eqIdx) === "PATH") {
|
|
13221
|
-
const dirs = trimmed.slice(eqIdx + 1).split(";").filter(Boolean);
|
|
13222
|
-
for (const dir of dirs) cmdParts.push("--path-prepend", `"${dir.replace(/\\+$/, "")}"`);
|
|
13223
|
-
} else cmdParts.push("--env", `"${trimmed}"`);
|
|
13224
|
-
}
|
|
13225
|
-
}
|
|
13226
|
-
}
|
|
13227
|
-
cmdParts.push("--", `"${config.executablePath}"`);
|
|
13228
|
-
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}"`));
|
|
13229
13734
|
const cmd = cmdParts.join(" ");
|
|
13230
13735
|
execSync(cmd, { stdio: "pipe" });
|
|
13231
13736
|
const dirSuffix = config.component && config.instanceDir ? ` [KiCI-DIR:${config.instanceDir}]` : "";
|
|
@@ -13237,60 +13742,55 @@ var init_windows = __esmMin((() => {
|
|
|
13237
13742
|
execSync(`sc.exe failure ${config.name} reset= ${resetSeconds} actions= ${actions}`, { stdio: "pipe" });
|
|
13238
13743
|
}
|
|
13239
13744
|
async uninstall(config) {
|
|
13745
|
+
const waitSeconds = stopWaitSeconds(config);
|
|
13240
13746
|
try {
|
|
13241
13747
|
execSync(`sc.exe stop ${config.name}`, { stdio: "pipe" });
|
|
13242
|
-
await new Promise((r) => setTimeout(r, 3e3));
|
|
13243
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
|
+
}
|
|
13244
13755
|
execSync(`sc.exe delete ${config.name}`, { stdio: "pipe" });
|
|
13245
|
-
const deadline = Date.now() +
|
|
13246
|
-
while (Date.now() < deadline)
|
|
13247
|
-
|
|
13248
|
-
await
|
|
13249
|
-
} catch {
|
|
13250
|
-
return;
|
|
13756
|
+
const deadline = Date.now() + waitSeconds * 1e3;
|
|
13757
|
+
while (Date.now() < deadline) {
|
|
13758
|
+
if (!this.serviceExists(config.name)) return;
|
|
13759
|
+
await sleep(1e3);
|
|
13251
13760
|
}
|
|
13252
|
-
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.`);
|
|
13253
13762
|
}
|
|
13254
13763
|
async start(config) {
|
|
13255
|
-
const deadline = Date.now() +
|
|
13764
|
+
const deadline = Date.now() + stopWaitSeconds(config) * 1e3;
|
|
13256
13765
|
while (Date.now() < deadline) {
|
|
13257
|
-
|
|
13258
|
-
try {
|
|
13259
|
-
stateCode = parseStateCode(execSync(`sc.exe query ${config.name}`, { stdio: "pipe" }).toString());
|
|
13260
|
-
} catch {
|
|
13261
|
-
stateCode = void 0;
|
|
13262
|
-
}
|
|
13766
|
+
const stateCode = this.queryStateCode(config.name);
|
|
13263
13767
|
if (stateCode === 4) return;
|
|
13264
13768
|
if (stateCode === 1) break;
|
|
13265
|
-
await
|
|
13769
|
+
await sleep(500);
|
|
13266
13770
|
}
|
|
13267
13771
|
execSync(`sc.exe start ${config.name}`, { stdio: "pipe" });
|
|
13268
13772
|
}
|
|
13269
13773
|
async stop(config) {
|
|
13270
|
-
execSync(`sc.exe stop ${config.name}`, { stdio: "pipe" });
|
|
13271
|
-
const deadline = Date.now() + 3e4;
|
|
13272
|
-
while (Date.now() < deadline) {
|
|
13273
|
-
let stateCode;
|
|
13274
|
-
try {
|
|
13275
|
-
stateCode = parseStateCode(execSync(`sc.exe query ${config.name}`, { stdio: "pipe" }).toString());
|
|
13276
|
-
} catch {
|
|
13277
|
-
stateCode = void 0;
|
|
13278
|
-
}
|
|
13279
|
-
if (stateCode === 1) return;
|
|
13280
|
-
await new Promise((r) => setTimeout(r, 500));
|
|
13281
|
-
}
|
|
13282
|
-
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.`);
|
|
13283
|
-
}
|
|
13284
|
-
async restart(config) {
|
|
13285
13774
|
try {
|
|
13286
13775
|
execSync(`sc.exe stop ${config.name}`, { stdio: "pipe" });
|
|
13287
|
-
} catch {
|
|
13288
|
-
|
|
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;
|
|
13289
13785
|
while (Date.now() < deadline) {
|
|
13290
|
-
if (
|
|
13291
|
-
await
|
|
13786
|
+
if (this.queryStateCode(config.name) === 1) return;
|
|
13787
|
+
await sleep(500);
|
|
13292
13788
|
}
|
|
13293
|
-
|
|
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);
|
|
13294
13794
|
}
|
|
13295
13795
|
async status(config) {
|
|
13296
13796
|
try {
|
|
@@ -13359,18 +13859,38 @@ var init_windows = __esmMin((() => {
|
|
|
13359
13859
|
}
|
|
13360
13860
|
return out;
|
|
13361
13861
|
}
|
|
13362
|
-
|
|
13363
|
-
|
|
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) {
|
|
13364
13873
|
try {
|
|
13365
|
-
|
|
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, "''")}`;
|
|
13878
|
+
try {
|
|
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;
|
|
13366
13880
|
} catch {
|
|
13367
13881
|
return null;
|
|
13368
13882
|
}
|
|
13369
|
-
|
|
13370
|
-
|
|
13371
|
-
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 === "--");
|
|
13372
13892
|
if (sep === -1) return null;
|
|
13373
|
-
const tokens =
|
|
13893
|
+
const tokens = unwrapServiceCommand(parts.slice(sep + 1).map((p) => p.text));
|
|
13374
13894
|
if (tokens.length === 0 || !tokens[0]) return null;
|
|
13375
13895
|
return {
|
|
13376
13896
|
execPath: tokens[0],
|
|
@@ -13395,56 +13915,6 @@ function composeFilePath(envFilePath, serviceName) {
|
|
|
13395
13915
|
}
|
|
13396
13916
|
var init_compose_path = __esmMin((() => {}));
|
|
13397
13917
|
//#endregion
|
|
13398
|
-
//#region src/cli/service/image-digests.ts
|
|
13399
|
-
/**
|
|
13400
|
-
* Resolves the customer installer's image reference from the digest record
|
|
13401
|
-
* shipped with this package (installer-image-digests.json at the package root).
|
|
13402
|
-
*
|
|
13403
|
-
* Emits a manifest-list-digest-pinned ref so a `docker`/`podman pull` verifies
|
|
13404
|
-
* the image hash — a registry that serves substituted bits fails the pull. When
|
|
13405
|
-
* the record is missing (a dev tree that never ran a release) or lacks the
|
|
13406
|
-
* image, falls back to the moving `:latest` tag and warns.
|
|
13407
|
-
*
|
|
13408
|
-
* The record resolves relative to this module's directory and works for both
|
|
13409
|
-
* layouts: the source tree (`src/cli/service/` → the JSON is three dirs up at
|
|
13410
|
-
* the package root) and the single-file bundle (`dist/cli.js` → one dir up).
|
|
13411
|
-
*/
|
|
13412
|
-
/**
|
|
13413
|
-
* Climb from a starting directory looking for installer-image-digests.json.
|
|
13414
|
-
* Works for the source layout (src/cli/service → 3 up) and the single-file
|
|
13415
|
-
* bundle layout (dist → 1 up).
|
|
13416
|
-
*/
|
|
13417
|
-
function findRecordFile(startDir) {
|
|
13418
|
-
let dir = startDir;
|
|
13419
|
-
for (let i = 0; i < 6; i++) {
|
|
13420
|
-
const candidate = path.join(dir, RECORD_FILENAME);
|
|
13421
|
-
if (existsSync(candidate)) return candidate;
|
|
13422
|
-
const parent = path.dirname(dir);
|
|
13423
|
-
if (parent === dir) break;
|
|
13424
|
-
dir = parent;
|
|
13425
|
-
}
|
|
13426
|
-
return null;
|
|
13427
|
-
}
|
|
13428
|
-
/**
|
|
13429
|
-
* Resolve `quay.io/kici-dev/<name>:<version>@sha256:<digest>` for an installer
|
|
13430
|
-
* image. Falls back to `:latest` (with a warning) when no recorded digest exists.
|
|
13431
|
-
*/
|
|
13432
|
-
function resolveImageRef(name, opts = {}) {
|
|
13433
|
-
const file = opts.filePath ?? findRecordFile(import.meta.dirname);
|
|
13434
|
-
if (file && existsSync(file)) try {
|
|
13435
|
-
const rec = JSON.parse(readFileSync(file, "utf8"));
|
|
13436
|
-
const digest = rec.images?.[name];
|
|
13437
|
-
if (digest && rec.version) return `${QUAY_PREFIX}/${name}:${rec.version}@${digest}`;
|
|
13438
|
-
} catch {}
|
|
13439
|
-
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.`);
|
|
13440
|
-
return `${QUAY_PREFIX}/${name}:latest`;
|
|
13441
|
-
}
|
|
13442
|
-
var QUAY_PREFIX, RECORD_FILENAME;
|
|
13443
|
-
var init_image_digests = __esmMin((() => {
|
|
13444
|
-
QUAY_PREFIX = "quay.io/kici-dev";
|
|
13445
|
-
RECORD_FILENAME = "installer-image-digests.json";
|
|
13446
|
-
}));
|
|
13447
|
-
//#endregion
|
|
13448
13918
|
//#region src/cli/service/compose.ts
|
|
13449
13919
|
/**
|
|
13450
13920
|
* Docker/Podman Compose service manager.
|
|
@@ -13553,29 +14023,6 @@ function getRestartMode(config) {
|
|
|
13553
14023
|
return "unless-stopped";
|
|
13554
14024
|
}
|
|
13555
14025
|
/**
|
|
13556
|
-
* Seconds a runtime must wait after SIGTERM before it SIGKILLs the container.
|
|
13557
|
-
*
|
|
13558
|
-
* Compose defaults this to 10s, which is shorter than either component's own
|
|
13559
|
-
* graceful-shutdown budget, so a teardown that takes longer than 10s is killed
|
|
13560
|
-
* partway through: the orchestrator never broadcasts `peer.leaving` and never
|
|
13561
|
-
* closes its agent sockets, and the agent never finishes draining. That window
|
|
13562
|
-
* is reached exactly when it matters most — the orchestrator's scaler stops
|
|
13563
|
-
* each managed agent container with its own grace, so an orchestrator with
|
|
13564
|
-
* agents in flight routinely needs more than 10s.
|
|
13565
|
-
*
|
|
13566
|
-
* Each value is its component's force-exit budget plus headroom, so the
|
|
13567
|
-
* process always reaches its own timer first and the runtime's kill stays the
|
|
13568
|
-
* last resort it is meant to be:
|
|
13569
|
-
* - orchestrator: 30s (`setupGracefulShutdown` default) -> 45s
|
|
13570
|
-
* - agent: 10s plus a ~1s abort delay in its `onForceExit` -> 20s
|
|
13571
|
-
*
|
|
13572
|
-
* An unrecognised component gets the larger value: over-waiting costs a few
|
|
13573
|
-
* seconds on shutdown, under-waiting corrupts it.
|
|
13574
|
-
*/
|
|
13575
|
-
function stopGracePeriodSeconds(config) {
|
|
13576
|
-
return (config.component ?? (config.name === "kici-agent" ? "agent" : "orchestrator")) === "agent" ? 20 : 45;
|
|
13577
|
-
}
|
|
13578
|
-
/**
|
|
13579
14026
|
* Generate compose YAML content for a service.
|
|
13580
14027
|
*
|
|
13581
14028
|
* Uses a manual YAML builder to avoid adding a YAML library dependency.
|
|
@@ -13583,25 +14030,24 @@ function stopGracePeriodSeconds(config) {
|
|
|
13583
14030
|
* restart policy, and volume mounts.
|
|
13584
14031
|
*
|
|
13585
14032
|
* The `image:` line is pinned by manifest-list digest via `resolveImageRef`
|
|
13586
|
-
* (`quay.io/kici-dev
|
|
13587
|
-
* to the mutable `:latest` tag only when no
|
|
13588
|
-
* package.
|
|
13589
|
-
*
|
|
13590
|
-
* .claude/rules/container-publish-registry.md — Quay is the sole registry for
|
|
13591
|
-
* 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.
|
|
13592
14037
|
*/
|
|
13593
14038
|
function generateComposeYaml(config) {
|
|
13594
14039
|
const restartMode = getRestartMode(config);
|
|
14040
|
+
const image = config.component ? installerImageName(config.component) : config.name;
|
|
13595
14041
|
const lines = [
|
|
13596
14042
|
"# Generated by KiCI service installer",
|
|
13597
14043
|
`# Service: ${config.displayName}`,
|
|
13598
14044
|
"",
|
|
13599
14045
|
"services:",
|
|
13600
14046
|
` ${config.name}:`,
|
|
13601
|
-
` image: ${resolveImageRef(
|
|
14047
|
+
` image: ${resolveImageRef(image)}`,
|
|
13602
14048
|
` container_name: ${config.name}`,
|
|
13603
14049
|
` restart: ${restartMode}`,
|
|
13604
|
-
` stop_grace_period: ${
|
|
14050
|
+
` stop_grace_period: ${shutdownGraceSeconds(config)}s`,
|
|
13605
14051
|
" env_file:",
|
|
13606
14052
|
` - ${config.envFilePath}`,
|
|
13607
14053
|
" volumes:",
|
|
@@ -13619,6 +14065,7 @@ var RUNTIME_PROBE_TIMEOUT_MS, ComposeServiceManager;
|
|
|
13619
14065
|
var init_compose = __esmMin((() => {
|
|
13620
14066
|
init_compose_path();
|
|
13621
14067
|
init_image_digests();
|
|
14068
|
+
init_shutdown_grace();
|
|
13622
14069
|
RUNTIME_PROBE_TIMEOUT_MS = 1e4;
|
|
13623
14070
|
ComposeServiceManager = class {
|
|
13624
14071
|
platform = "compose";
|
|
@@ -13773,11 +14220,14 @@ var init_compose = __esmMin((() => {
|
|
|
13773
14220
|
var service_exports = /* @__PURE__ */ __exportAll({
|
|
13774
14221
|
COMPONENTS: () => COMPONENTS,
|
|
13775
14222
|
DEFAULT_RESTART_POLICY: () => DEFAULT_RESTART_POLICY,
|
|
14223
|
+
ENV_FILE_LOADER_EXPORT: () => ENV_FILE_LOADER_EXPORT,
|
|
13776
14224
|
InstanceNotFoundError: () => InstanceNotFoundError,
|
|
14225
|
+
SERVICE_TEXT: () => SERVICE_TEXT,
|
|
13777
14226
|
appendIndexEntry: () => appendIndexEntry,
|
|
13778
14227
|
candidatePlatforms: () => candidatePlatforms,
|
|
13779
14228
|
createServiceManager: () => createServiceManager,
|
|
13780
14229
|
detectPlatform: () => detectPlatform,
|
|
14230
|
+
envFileRefusal: () => envFileRefusal,
|
|
13781
14231
|
formatRefusal: () => formatRefusal,
|
|
13782
14232
|
getCacheDir: () => getCacheDir,
|
|
13783
14233
|
getConfigDir: () => getConfigDir,
|
|
@@ -13786,6 +14236,8 @@ var service_exports = /* @__PURE__ */ __exportAll({
|
|
|
13786
14236
|
isComponent: () => isComponent,
|
|
13787
14237
|
isRoot: () => isRoot,
|
|
13788
14238
|
kiciConfigRoot: () => kiciConfigRoot,
|
|
14239
|
+
launchReadsEnvFile: () => launchReadsEnvFile,
|
|
14240
|
+
launchedRelease: () => launchedRelease,
|
|
13789
14241
|
listInstances: () => listInstances,
|
|
13790
14242
|
manifestFilename: () => manifestFilename,
|
|
13791
14243
|
manifestPath: () => manifestPath,
|
|
@@ -13795,10 +14247,13 @@ var service_exports = /* @__PURE__ */ __exportAll({
|
|
|
13795
14247
|
removeIndexEntry: () => removeIndexEntry,
|
|
13796
14248
|
resolveInstance: () => resolveInstance,
|
|
13797
14249
|
resolveInstanceTarget: () => resolveInstanceTarget,
|
|
14250
|
+
resolveLauncherVersion: () => resolveLauncherVersion,
|
|
13798
14251
|
resolveNpmInstallTarget: () => resolveNpmInstallTarget,
|
|
13799
14252
|
resolveUserLevel: () => resolveUserLevel,
|
|
13800
14253
|
resolveVersionFromLaunchSpec: () => resolveVersionFromLaunchSpec,
|
|
14254
|
+
restrictEnvFileAccess: () => restrictEnvFileAccess,
|
|
13801
14255
|
writeIndex: () => writeIndex,
|
|
14256
|
+
writeInstallManifest: () => writeInstallManifest,
|
|
13802
14257
|
writeManifest: () => writeManifest
|
|
13803
14258
|
});
|
|
13804
14259
|
/**
|
|
@@ -13836,6 +14291,8 @@ var init_service = __esmMin((() => {
|
|
|
13836
14291
|
init_types$1();
|
|
13837
14292
|
init_platform_detect();
|
|
13838
14293
|
init_privilege();
|
|
14294
|
+
init_windows_acl();
|
|
14295
|
+
init_windows_env_file();
|
|
13839
14296
|
init_types();
|
|
13840
14297
|
init_manifest();
|
|
13841
14298
|
init_index_file();
|
|
@@ -15187,7 +15644,7 @@ function startManifestLoopback(opts) {
|
|
|
15187
15644
|
* blocked launcher) just falls back to copy-paste.
|
|
15188
15645
|
*
|
|
15189
15646
|
* `KICI_BROWSER_CMD` overrides the launcher: `none` suppresses it entirely
|
|
15190
|
-
* (
|
|
15647
|
+
* (automated tests / headless capture), any other value is run with `{url}` substituted —
|
|
15191
15648
|
* mirroring the `kici login` convention.
|
|
15192
15649
|
*/
|
|
15193
15650
|
function openBrowserBestEffort(url) {
|
|
@@ -16267,10 +16724,9 @@ function registerRemoteSourceCommands(program) {
|
|
|
16267
16724
|
* Workflow registration commands for kici-admin.
|
|
16268
16725
|
*
|
|
16269
16726
|
* Provides operator-facing read access to the workflow_registrations table
|
|
16270
|
-
* via the orchestrator's admin HTTP API.
|
|
16271
|
-
*
|
|
16272
|
-
*
|
|
16273
|
-
* 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.
|
|
16274
16730
|
*
|
|
16275
16731
|
* workflow list List registered workflows (table or JSON)
|
|
16276
16732
|
*
|
|
@@ -16353,7 +16809,7 @@ function renderTable$3(headers, rows) {
|
|
|
16353
16809
|
*/
|
|
16354
16810
|
function registerWorkflowCommands(program, getClient) {
|
|
16355
16811
|
const wf = program.command("workflow").description("Inspect workflow registrations");
|
|
16356
|
-
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) => {
|
|
16357
16813
|
try {
|
|
16358
16814
|
const path = `/api/v1/admin/registrations${buildQueryString({
|
|
16359
16815
|
org: opts.org,
|
|
@@ -16477,7 +16933,7 @@ function emptyStepLogNote(stepIndex, logs) {
|
|
|
16477
16933
|
*/
|
|
16478
16934
|
function registerRunsCommands(program, getClient) {
|
|
16479
16935
|
const runs = program.command("runs").description("Inspect execution runs, jobs, and steps");
|
|
16480
|
-
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) => {
|
|
16481
16937
|
try {
|
|
16482
16938
|
if (opts.count) {
|
|
16483
16939
|
const data = await getClient().countRuns({
|
|
@@ -16680,7 +17136,7 @@ function registerRunsCommands(program, getClient) {
|
|
|
16680
17136
|
process.exit(1);
|
|
16681
17137
|
}
|
|
16682
17138
|
});
|
|
16683
|
-
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) => {
|
|
16684
17140
|
try {
|
|
16685
17141
|
const response = await getClient().getRunJobs(runId, { includeSteps: Boolean(opts.includeSteps) });
|
|
16686
17142
|
if (opts.json) {
|
|
@@ -16734,7 +17190,7 @@ function registerRunsCommands(program, getClient) {
|
|
|
16734
17190
|
process.exit(1);
|
|
16735
17191
|
}
|
|
16736
17192
|
});
|
|
16737
|
-
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) => {
|
|
16738
17194
|
try {
|
|
16739
17195
|
const params = new URLSearchParams({ limit: String(opts.limit) });
|
|
16740
17196
|
if (opts.cursor) params.set("cursor", String(opts.cursor));
|
|
@@ -16844,6 +17300,8 @@ function clusterSentinelKey(prefix) {
|
|
|
16844
17300
|
* Provider configuration (GitHub Apps, etc.) is managed via the `sources` table
|
|
16845
17301
|
* and PgSecretStore, not through config. See SourceStore and SourceManager.
|
|
16846
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;
|
|
16847
17305
|
/**
|
|
16848
17306
|
* Default for `sealed_secrets_retry_backoff_ms`: how long a coordinator stops
|
|
16849
17307
|
* offering a queued job it put back because it could not open its seal. The
|
|
@@ -16854,7 +17312,7 @@ const DEFAULT_SEALED_SECRETS_RETRY_BACKOFF_MS = 6e4;
|
|
|
16854
17312
|
const PLATFORM_CONNECTED_MODES_TEXT = `${PLATFORM_CONNECTED_MODES.slice(0, -1).join(", ")}, or ${PLATFORM_CONNECTED_MODES[PLATFORM_CONNECTED_MODES.length - 1]}`;
|
|
16855
17313
|
const baseSchema = z.object({
|
|
16856
17314
|
mode: OrchestratorMode.default("platform"),
|
|
16857
|
-
port: z.coerce.number().default(
|
|
17315
|
+
port: z.coerce.number().default(ORCHESTRATOR_DEFAULT_PORT),
|
|
16858
17316
|
/**
|
|
16859
17317
|
* Address the HTTP + WebSocket listener binds. Defaults to every interface,
|
|
16860
17318
|
* matching the Platform's own `KICI_HOST`, because a real orchestrator
|
|
@@ -16931,7 +17389,7 @@ const baseSchema = z.object({
|
|
|
16931
17389
|
orchestratorUrl: z.string().optional(),
|
|
16932
17390
|
databaseUrl: z.string().default(""),
|
|
16933
17391
|
dbPoolMax: z.coerce.number().int().positive().default(20),
|
|
16934
|
-
dbPoolAcquireTimeoutMs: z.coerce.number().int().nonnegative().default(
|
|
17392
|
+
dbPoolAcquireTimeoutMs: z.coerce.number().int().nonnegative().default(DB_POOL_ACQUIRE_TIMEOUT_DEFAULT_MS),
|
|
16935
17393
|
dbStatementTimeoutMs: z.coerce.number().int().nonnegative().default(3e4),
|
|
16936
17394
|
lockfileCacheMax: z.coerce.number().default(500),
|
|
16937
17395
|
lockfileCacheTtlMs: z.coerce.number().default(36e5),
|
|
@@ -17220,7 +17678,7 @@ const configSchema = baseSchema.superRefine((data, ctx) => {
|
|
|
17220
17678
|
const packagingConfigSchema = baseSchema.superRefine(refineStorageShape);
|
|
17221
17679
|
/**
|
|
17222
17680
|
* Env-var definition for the orchestrator. Exported so the docs generator and
|
|
17223
|
-
*
|
|
17681
|
+
* config pre-validation can re-parse without going through process.env.
|
|
17224
17682
|
*
|
|
17225
17683
|
* Note: passes the inner `baseSchema` (a ZodObject) so describe() can walk
|
|
17226
17684
|
* `.shape`, but uses the outer `configSchema` (with .superRefine) as the parser
|
|
@@ -17468,8 +17926,8 @@ const COLD_STORE_ENV_VARS = [
|
|
|
17468
17926
|
"KICI_COLD_STORE_EVENT_LOG_ENABLED"
|
|
17469
17927
|
];
|
|
17470
17928
|
/**
|
|
17471
|
-
* Deployment-identity env vars injected by the installer (
|
|
17472
|
-
*
|
|
17929
|
+
* Deployment-identity env vars injected by the installer (or other deploy
|
|
17930
|
+
* tooling) so the orchestrator can report its own deployment shape in
|
|
17473
17931
|
* `source.register`. Read directly from `process.env` by the deployment reader,
|
|
17474
17932
|
* not threaded through the AppConfig schema — registered here so the
|
|
17475
17933
|
* unknown-KICI_* validator at boot doesn't reject them.
|
|
@@ -17847,7 +18305,8 @@ var S3CacheStorage = class {
|
|
|
17847
18305
|
* Signature input: `${method}:${key}:${expiresUnixMs}` with HMAC-SHA256 keyed
|
|
17848
18306
|
* by a per-orchestrator-process secret (random 32-byte hex at boot, persists
|
|
17849
18307
|
* for the lifetime of the process). Tokens become invalid on orchestrator
|
|
17850
|
-
* restart — fine for the filesystem backend's
|
|
18308
|
+
* restart — fine for the filesystem backend's single-host / test-sandbox use
|
|
18309
|
+
* case.
|
|
17851
18310
|
*/
|
|
17852
18311
|
/** Default URL lifetime: 1 hour (matches S3 backend's PUT-URL lifetime). */
|
|
17853
18312
|
const DEFAULT_SIGN_URL_TTL_MS = 36e5;
|
|
@@ -17883,7 +18342,7 @@ function signToken(secret, method, key, ttlMs = DEFAULT_SIGN_URL_TTL_MS) {
|
|
|
17883
18342
|
* are replaced by HMAC-signed `http://<orchUrl>/api/v1/cache/blob/...` URLs
|
|
17884
18343
|
* — see `sign-url.ts` for the token mechanics and `app.ts` for the route.
|
|
17885
18344
|
*
|
|
17886
|
-
* Intended for single-host deployments and
|
|
18345
|
+
* Intended for single-host deployments and test sandboxes where standing up
|
|
17887
18346
|
* an S3-compatible service is overkill. Production should still use S3.
|
|
17888
18347
|
*
|
|
17889
18348
|
* Metadata is co-located as a sibling JSON file (`<key>.meta.json`) so the
|
|
@@ -18153,7 +18612,7 @@ function createCacheStorage(config) {
|
|
|
18153
18612
|
//#region src/provenance/trust-root.ts
|
|
18154
18613
|
/**
|
|
18155
18614
|
* Derive the JWKS URI for a provenance OIDC issuer. Identical to the Platform's
|
|
18156
|
-
* own derivation
|
|
18615
|
+
* own derivation so a bundle
|
|
18157
18616
|
* verified client-side and at ingest resolve the same key set.
|
|
18158
18617
|
*/
|
|
18159
18618
|
function deriveJwksUri(issuer) {
|
|
@@ -18662,7 +19121,7 @@ var OrchestratorSigningKeyRepo = class {
|
|
|
18662
19121
|
* The in-memory private key is imported NON-EXTRACTABLE (`extractable: false`),
|
|
18663
19122
|
* so once loaded the raw private material can never be read back out of the
|
|
18664
19123
|
* process — the only export path is the public JWK. This is what makes the
|
|
18665
|
-
* private key "non-exportable by design"
|
|
19124
|
+
* private key "non-exportable by design".
|
|
18666
19125
|
*/
|
|
18667
19126
|
/** AAD binding the wrapped private JWK to its purpose (AES-GCM authenticated data). */
|
|
18668
19127
|
const SIGNING_KEY_AAD = "orchestrator-signing-key";
|
|
@@ -18775,8 +19234,7 @@ var DbSigner = class DbSigner {
|
|
|
18775
19234
|
//#region src/cli/commands/signing-key.ts
|
|
18776
19235
|
/**
|
|
18777
19236
|
* `kici-admin signing-key` — orchestrator-owned provenance signing key
|
|
18778
|
-
* management (orchestrator DB plane
|
|
18779
|
-
* .claude/rules/platform-admin.md).
|
|
19237
|
+
* management (orchestrator DB plane, so it belongs to `kici-admin`).
|
|
18780
19238
|
*
|
|
18781
19239
|
* signing-key list List keys (kid / status / created_at).
|
|
18782
19240
|
* Reads the database when a database URL
|
|
@@ -19317,6 +19775,41 @@ function encryptPrivateKey(privateKey, secretKey) {
|
|
|
19317
19775
|
}
|
|
19318
19776
|
createLogger({ prefix: "job-secret-seal" });
|
|
19319
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
|
+
}
|
|
19320
19813
|
createLogger({ prefix: "instance-heartbeat" });
|
|
19321
19814
|
createLogger({ prefix: "job-queue" });
|
|
19322
19815
|
/**
|
|
@@ -19485,7 +19978,7 @@ function renderTable$1(headers, rows) {
|
|
|
19485
19978
|
}
|
|
19486
19979
|
function registerEventLogCommands(program, getClient) {
|
|
19487
19980
|
const eventLog = program.command("event-log").description("Inspect the inbound webhook delivery log");
|
|
19488
|
-
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) => {
|
|
19489
19982
|
try {
|
|
19490
19983
|
const response = await getClient().listEventLog({
|
|
19491
19984
|
orgId: opts.org,
|
|
@@ -19599,7 +20092,7 @@ function renderTable(headers, rows) {
|
|
|
19599
20092
|
}
|
|
19600
20093
|
function registerAccessLogCommands(program, getClient) {
|
|
19601
20094
|
const accessLog = program.command("access-log").description("Inspect the read / admin-mutation access log");
|
|
19602
|
-
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) => {
|
|
19603
20096
|
try {
|
|
19604
20097
|
const response = await getClient().listAccessLog({
|
|
19605
20098
|
orgId: opts.orgId,
|
|
@@ -20268,6 +20761,16 @@ function registerInspectBundleCommand(program) {
|
|
|
20268
20761
|
//#endregion
|
|
20269
20762
|
//#region src/cli/service/entrypoint.ts
|
|
20270
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
|
+
/**
|
|
20271
20774
|
* Pick the orchestrator server entry from an env file's `KICI_MODE`.
|
|
20272
20775
|
* `independent` runs the standalone server; `platform` / `hybrid` / unset run
|
|
20273
20776
|
* the Platform-connected server.
|
|
@@ -20292,6 +20795,34 @@ function resolveServiceExecutable(opts) {
|
|
|
20292
20795
|
args: [opts.entryScript]
|
|
20293
20796
|
};
|
|
20294
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
|
+
}
|
|
20295
20826
|
//#endregion
|
|
20296
20827
|
//#region src/cli/service/deploy-env.ts
|
|
20297
20828
|
init_compose_path();
|
|
@@ -20370,6 +20901,11 @@ async function waitForQuiesce(poll, opts) {
|
|
|
20370
20901
|
}
|
|
20371
20902
|
}
|
|
20372
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
|
+
/**
|
|
20373
20909
|
* The last applied migration reported by a running service.
|
|
20374
20910
|
*
|
|
20375
20911
|
* Returns null when nothing has been applied — a fresh database — which the
|
|
@@ -20389,17 +20925,18 @@ function migrationsAfter(rows, head) {
|
|
|
20389
20925
|
* Decide whether the version pointer may move.
|
|
20390
20926
|
*
|
|
20391
20927
|
* Three outcomes, and the middle one is the compatibility shim: an instance
|
|
20392
|
-
* installed before `migrationHeads` existed
|
|
20393
|
-
*
|
|
20394
|
-
*
|
|
20395
|
-
*
|
|
20396
|
-
*
|
|
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.
|
|
20397
20934
|
*/
|
|
20398
20935
|
function schemaGuardVerdict(input) {
|
|
20399
20936
|
const { targetVersion, recordedHead, liveRows } = input;
|
|
20400
20937
|
if (!recordedHead) return {
|
|
20401
20938
|
kind: "unknown-head",
|
|
20402
|
-
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.`
|
|
20403
20940
|
};
|
|
20404
20941
|
const live = appliedHead(liveRows);
|
|
20405
20942
|
if (live === null || live === recordedHead) return { kind: "ok" };
|
|
@@ -20486,8 +21023,7 @@ async function resolveUpgradeTarget(args) {
|
|
|
20486
21023
|
return {
|
|
20487
21024
|
config: {
|
|
20488
21025
|
name: resolved.manifest.name,
|
|
20489
|
-
|
|
20490
|
-
description: `KiCI ${component} service`,
|
|
21026
|
+
...SERVICE_TEXT[component],
|
|
20491
21027
|
executablePath: "",
|
|
20492
21028
|
envFilePath: resolved.manifest.envFilePath,
|
|
20493
21029
|
workingDirectory: resolved.manifest.configDir,
|
|
@@ -20522,8 +21058,8 @@ async function downloadArchive(url, destPath) {
|
|
|
20522
21058
|
* Install base for a KiCI component instance.
|
|
20523
21059
|
*
|
|
20524
21060
|
* Name-scoped so that two instances of the same component (e.g. an org's
|
|
20525
|
-
*
|
|
20526
|
-
* 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:
|
|
20527
21063
|
* - systemd / compose: /opt/kici/<name>/
|
|
20528
21064
|
* - launchd: /usr/local/kici/<name>/
|
|
20529
21065
|
* - windows: C:\Program Files\KiCI\<name>\
|
|
@@ -20575,10 +21111,73 @@ function checkPickFlagConflicts(opts) {
|
|
|
20575
21111
|
if (conflicts.length === 0) return null;
|
|
20576
21112
|
return `--pick cannot be combined with ${conflicts.join(", ")}. --pick activates an already-installed version; it never downloads or takes an explicit version.`;
|
|
20577
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
|
+
}
|
|
20578
21125
|
/** Check if the platform is Windows. */
|
|
20579
21126
|
function isWindows(platform) {
|
|
20580
21127
|
return platform === "windows";
|
|
20581
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
|
+
}
|
|
20582
21181
|
/** Get the launcher script name for a component. */
|
|
20583
21182
|
function getLauncherName(component, platform) {
|
|
20584
21183
|
const baseName = component === "orchestrator" ? "kici-orchestrator-standalone" : "kici-agent";
|
|
@@ -20599,6 +21198,45 @@ function extractArchive(archivePath, destDir) {
|
|
|
20599
21198
|
return dirs[0];
|
|
20600
21199
|
}
|
|
20601
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
|
+
/**
|
|
20602
21240
|
* List installed versions for a component by scanning the install base directory
|
|
20603
21241
|
* for directories matching `{component}-{version}/`.
|
|
20604
21242
|
*/
|
|
@@ -20747,18 +21385,87 @@ function planNpmSourceUpgrade(opts) {
|
|
|
20747
21385
|
version: requestedVersion
|
|
20748
21386
|
};
|
|
20749
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
|
+
}
|
|
20750
21401
|
const defaultSpawnRunner = (cmd, args, opts) => {
|
|
20751
|
-
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, {
|
|
20752
21408
|
encoding: "utf-8",
|
|
20753
|
-
|
|
20754
|
-
env: opts?.env ?? process.env
|
|
21409
|
+
env
|
|
20755
21410
|
});
|
|
20756
21411
|
return {
|
|
20757
21412
|
status: r.status,
|
|
21413
|
+
stdout: r.stdout ?? "",
|
|
20758
21414
|
stderr: (r.stderr ?? "") + (r.error ? String(r.error) : "")
|
|
20759
21415
|
};
|
|
20760
21416
|
};
|
|
20761
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
|
+
/**
|
|
20762
21469
|
* Install `<owningPackage>@<version>` globally under the unit's own pinned node,
|
|
20763
21470
|
* so it lands in exactly the global prefix the unit's ExecStart resolves from.
|
|
20764
21471
|
*
|
|
@@ -20773,16 +21480,11 @@ const defaultSpawnRunner = (cmd, args, opts) => {
|
|
|
20773
21480
|
* `run` seam is injected in tests.
|
|
20774
21481
|
*/
|
|
20775
21482
|
function installGlobalPackage(target, version, run = defaultSpawnRunner) {
|
|
20776
|
-
const pinnedBinDir = path.dirname(target.nodeExecPath);
|
|
20777
|
-
const env = {
|
|
20778
|
-
...process.env,
|
|
20779
|
-
PATH: `${pinnedBinDir}${path.delimiter}${process.env.PATH ?? ""}`
|
|
20780
|
-
};
|
|
20781
21483
|
const res = run(target.npmPath, [
|
|
20782
21484
|
"install",
|
|
20783
21485
|
"-g",
|
|
20784
21486
|
`${target.owningPackage}@${version}`
|
|
20785
|
-
], { env });
|
|
21487
|
+
], { env: npmEnv(target) });
|
|
20786
21488
|
return {
|
|
20787
21489
|
ok: res.status === 0,
|
|
20788
21490
|
stderr: res.stderr
|
|
@@ -20891,18 +21593,10 @@ async function performVersionedUpgrade(component, opts, hooks = {}) {
|
|
|
20891
21593
|
const version = opts.version;
|
|
20892
21594
|
const versionedDirName = `${component}-${version}`;
|
|
20893
21595
|
const versionedDirPath = path.join(installBase, versionedDirName);
|
|
20894
|
-
if (fs.existsSync(versionedDirPath)) {
|
|
20895
|
-
|
|
20896
|
-
|
|
20897
|
-
|
|
20898
|
-
recursive: true,
|
|
20899
|
-
force: true
|
|
20900
|
-
});
|
|
20901
|
-
} else {
|
|
20902
|
-
console.error(`Error: version directory already exists: ${versionedDirPath}`);
|
|
20903
|
-
console.error("Use --force to overwrite.");
|
|
20904
|
-
process.exit(1);
|
|
20905
|
-
}
|
|
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);
|
|
20906
21600
|
}
|
|
20907
21601
|
if (!await manager.isInstalled(config)) {
|
|
20908
21602
|
console.error(`Error: service "${config.name}" is not installed`);
|
|
@@ -20935,13 +21629,21 @@ async function performVersionedUpgrade(component, opts, hooks = {}) {
|
|
|
20935
21629
|
}
|
|
20936
21630
|
}
|
|
20937
21631
|
console.log("Extracting archive...");
|
|
20938
|
-
|
|
20939
|
-
|
|
20940
|
-
|
|
20941
|
-
|
|
20942
|
-
|
|
20943
|
-
|
|
20944
|
-
|
|
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
|
+
}
|
|
20945
21647
|
const preStopHeads = await runPreStopSafety({
|
|
20946
21648
|
component,
|
|
20947
21649
|
opts,
|
|
@@ -20955,10 +21657,7 @@ async function performVersionedUpgrade(component, opts, hooks = {}) {
|
|
|
20955
21657
|
}
|
|
20956
21658
|
if (isWindows(platform)) {
|
|
20957
21659
|
const launcherPath = path.join(versionedDirPath, getLauncherName(component, platform));
|
|
20958
|
-
config
|
|
20959
|
-
await manager.uninstall(config);
|
|
20960
|
-
await new Promise((r) => setTimeout(r, 2e3));
|
|
20961
|
-
await manager.install(config);
|
|
21660
|
+
await reregisterWindowsService(manager, config, launcherPath);
|
|
20962
21661
|
writeCurrentVersion(installBase, component, version);
|
|
20963
21662
|
console.log(`Service registration updated to ${launcherPath}`);
|
|
20964
21663
|
} else {
|
|
@@ -21023,17 +21722,36 @@ async function performNpmSourceUpgrade(component, config, resolvedInstance, mana
|
|
|
21023
21722
|
process.exit(1);
|
|
21024
21723
|
}
|
|
21025
21724
|
if (action.kind === "restart-only") {
|
|
21026
|
-
await restartOnlyUpgrade(
|
|
21725
|
+
await restartOnlyUpgrade({
|
|
21726
|
+
component,
|
|
21727
|
+
config,
|
|
21728
|
+
resolvedInstance,
|
|
21729
|
+
manager,
|
|
21730
|
+
verdict: action.verdict,
|
|
21731
|
+
opts,
|
|
21732
|
+
platform,
|
|
21733
|
+
spec
|
|
21734
|
+
});
|
|
21027
21735
|
return;
|
|
21028
21736
|
}
|
|
21029
|
-
await performSelfDrivingInstall(
|
|
21737
|
+
await performSelfDrivingInstall({
|
|
21738
|
+
component,
|
|
21739
|
+
config,
|
|
21740
|
+
resolvedInstance,
|
|
21741
|
+
manager,
|
|
21742
|
+
action,
|
|
21743
|
+
opts,
|
|
21744
|
+
platform,
|
|
21745
|
+
spec
|
|
21746
|
+
});
|
|
21030
21747
|
}
|
|
21031
21748
|
/**
|
|
21032
21749
|
* Restart-only path: the operator pre-installed `@kici-dev/<pkg>@<version>` (or
|
|
21033
21750
|
* kici-admin) themselves. Verify the unit will launch the invoking CLI's version,
|
|
21034
21751
|
* restart onto it, and persist the manifest version.
|
|
21035
21752
|
*/
|
|
21036
|
-
async function restartOnlyUpgrade(
|
|
21753
|
+
async function restartOnlyUpgrade(args) {
|
|
21754
|
+
const { config, resolvedInstance, manager, verdict, opts, platform, spec } = args;
|
|
21037
21755
|
if (!verdict.ok) {
|
|
21038
21756
|
console.error(`Error: ${verdict.reason}`);
|
|
21039
21757
|
process.exit(1);
|
|
@@ -21048,8 +21766,12 @@ async function restartOnlyUpgrade(component, config, resolvedInstance, manager,
|
|
|
21048
21766
|
}
|
|
21049
21767
|
}
|
|
21050
21768
|
console.log("Restarting service onto the npm-installed package...");
|
|
21051
|
-
|
|
21052
|
-
|
|
21769
|
+
await restartOntoInstalledPackage({
|
|
21770
|
+
manager,
|
|
21771
|
+
config,
|
|
21772
|
+
platform,
|
|
21773
|
+
spec
|
|
21774
|
+
});
|
|
21053
21775
|
if (verdict.manifestVersion !== null) writeManifest(resolvedInstance.instanceDir, {
|
|
21054
21776
|
...resolvedInstance.manifest,
|
|
21055
21777
|
kiciVersion: verdict.manifestVersion
|
|
@@ -21062,8 +21784,15 @@ async function restartOnlyUpgrade(component, config, resolvedInstance, manager,
|
|
|
21062
21784
|
* pinned node/npm, restart, and verify the unit now launches the requested
|
|
21063
21785
|
* version.
|
|
21064
21786
|
*/
|
|
21065
|
-
async function performSelfDrivingInstall(
|
|
21787
|
+
async function performSelfDrivingInstall(args) {
|
|
21788
|
+
const { component, config, resolvedInstance, manager, action, opts, platform, spec } = args;
|
|
21066
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
|
+
}
|
|
21067
21796
|
if (!opts.yes) {
|
|
21068
21797
|
console.log(`This will install ${target.owningPackage}@${version} using ${target.npmPath}`);
|
|
21069
21798
|
console.log(` (node runtime: ${target.nodeExecPath}), then restart "${config.name}".`);
|
|
@@ -21074,7 +21803,7 @@ async function performSelfDrivingInstall(component, config, resolvedInstance, ma
|
|
|
21074
21803
|
}
|
|
21075
21804
|
}
|
|
21076
21805
|
console.log(`Installing ${target.owningPackage}@${version} under the unit's runtime...`);
|
|
21077
|
-
const result = installGlobalPackage(target, version);
|
|
21806
|
+
const result = installGlobalPackage(target, version, args.run);
|
|
21078
21807
|
if (!result.ok) {
|
|
21079
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." : "";
|
|
21080
21809
|
console.error(`Error: npm install failed for ${target.owningPackage}@${version}.${hint}`);
|
|
@@ -21082,8 +21811,12 @@ async function performSelfDrivingInstall(component, config, resolvedInstance, ma
|
|
|
21082
21811
|
process.exit(1);
|
|
21083
21812
|
}
|
|
21084
21813
|
console.log("Restarting service onto the freshly-installed package...");
|
|
21085
|
-
|
|
21086
|
-
|
|
21814
|
+
await restartOntoInstalledPackage({
|
|
21815
|
+
manager,
|
|
21816
|
+
config,
|
|
21817
|
+
platform,
|
|
21818
|
+
spec
|
|
21819
|
+
});
|
|
21087
21820
|
const newSpec = await manager.readLaunchSpec(config);
|
|
21088
21821
|
const launched = newSpec ? resolveVersionFromLaunchSpec(newSpec, component) : null;
|
|
21089
21822
|
if (launched !== version) {
|
|
@@ -21146,14 +21879,6 @@ async function handlePick(component, platform, installBase, config, manager, res
|
|
|
21146
21879
|
console.log(`Switched: ${current ?? "(unknown)"} -> ${target}`);
|
|
21147
21880
|
}
|
|
21148
21881
|
/**
|
|
21149
|
-
* Stop the service, switch the active version pointer to an already-installed
|
|
21150
|
-
* versioned directory (atomic symlink on Unix; uninstall→install + version file
|
|
21151
|
-
* on Windows), restart, and persist the new `kiciVersion` into the manifest.
|
|
21152
|
-
*
|
|
21153
|
-
* Shared by `--rollback` (switch to the previous version) and `--pick` (switch
|
|
21154
|
-
* to any chosen installed version).
|
|
21155
|
-
*/
|
|
21156
|
-
/**
|
|
21157
21882
|
* Refuse a version switch whose target cannot boot against the current schema.
|
|
21158
21883
|
*
|
|
21159
21884
|
* `--rollback` and `--pick` both flip a version pointer, and the previous
|
|
@@ -21171,7 +21896,7 @@ async function assertSchemaAllowsSwitch(args) {
|
|
|
21171
21896
|
const rows = await readLiveMigrationRows(hooks.migrationStatus);
|
|
21172
21897
|
if (rows === null) {
|
|
21173
21898
|
console.log("");
|
|
21174
|
-
console.log(
|
|
21899
|
+
console.log(` !! ${SCHEMA_NOT_CHECKED}`);
|
|
21175
21900
|
console.log("");
|
|
21176
21901
|
return yes ? true : confirm$1("Proceed with the switch anyway?");
|
|
21177
21902
|
}
|
|
@@ -21213,8 +21938,23 @@ async function assertSchemaAllowsSwitch(args) {
|
|
|
21213
21938
|
}
|
|
21214
21939
|
return true;
|
|
21215
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
|
+
*/
|
|
21216
21949
|
async function switchToInstalledVersion(args) {
|
|
21217
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
|
+
});
|
|
21218
21958
|
if (!await assertSchemaAllowsSwitch({
|
|
21219
21959
|
component,
|
|
21220
21960
|
targetVersion,
|
|
@@ -21232,10 +21972,7 @@ async function switchToInstalledVersion(args) {
|
|
|
21232
21972
|
console.log("Service stopped.");
|
|
21233
21973
|
}
|
|
21234
21974
|
if (isWindows(platform)) {
|
|
21235
|
-
|
|
21236
|
-
config.executablePath = launcherPath;
|
|
21237
|
-
await manager.uninstall(config);
|
|
21238
|
-
await manager.install(config);
|
|
21975
|
+
await reregisterWindowsService(manager, config, launcherPath);
|
|
21239
21976
|
writeCurrentVersion(installBase, component, targetVersion);
|
|
21240
21977
|
console.log(`Service registration updated to ${launcherPath}`);
|
|
21241
21978
|
} else {
|
|
@@ -21898,6 +22635,8 @@ Token vocabulary:
|
|
|
21898
22635
|
\`kici-admin join\` to add another orchestrator as a peer to an existing cluster.
|
|
21899
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) => {
|
|
21900
22637
|
try {
|
|
22638
|
+
const resolveModule = typeof import.meta.resolve === "function" ? (specifier) => import.meta.resolve(specifier) : void 0;
|
|
22639
|
+
assertServerResolvable("orchestrator", opts.binary, resolveModule);
|
|
21901
22640
|
if (opts.wizard === true && opts.envFile) {
|
|
21902
22641
|
console.error("Error: Cannot use --wizard with --env-file");
|
|
21903
22642
|
process.exit(1);
|
|
@@ -21930,7 +22669,7 @@ Token vocabulary:
|
|
|
21930
22669
|
})).find((c) => c.name === serviceName);
|
|
21931
22670
|
if (existing && existing.instanceDir !== instanceDir && !opts.force) {
|
|
21932
22671
|
const at = existing.instanceDir ?? "(no manifest)";
|
|
21933
|
-
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.`);
|
|
21934
22673
|
process.exit(1);
|
|
21935
22674
|
}
|
|
21936
22675
|
const configDir = getConfigDir(serviceName, userLevel);
|
|
@@ -21938,6 +22677,7 @@ Token vocabulary:
|
|
|
21938
22677
|
fs.mkdirSync(configDir, { recursive: true });
|
|
21939
22678
|
fs.mkdirSync(logDir, { recursive: true });
|
|
21940
22679
|
const envFilePath = path.join(configDir, `${serviceName}.env`);
|
|
22680
|
+
if (platform === "windows" && !userLevel) restrictEnvFileAccess(envFilePath);
|
|
21941
22681
|
let devDbUrl;
|
|
21942
22682
|
if (opts.dev) {
|
|
21943
22683
|
devDbUrl = startDevPostgres(`${serviceName}-dev-pg`);
|
|
@@ -21971,7 +22711,7 @@ Token vocabulary:
|
|
|
21971
22711
|
envFilePath
|
|
21972
22712
|
});
|
|
21973
22713
|
fs.writeFileSync(envFilePath, upsertDeployEnvLines(fs.readFileSync(envFilePath, "utf-8"), deployLines), "utf-8");
|
|
21974
|
-
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);
|
|
21975
22715
|
const { executablePath, args } = resolveServiceExecutable({
|
|
21976
22716
|
binary: opts.binary ? path.resolve(opts.binary) : void 0,
|
|
21977
22717
|
nodePath: process.execPath,
|
|
@@ -21987,8 +22727,7 @@ Token vocabulary:
|
|
|
21987
22727
|
} catch {}
|
|
21988
22728
|
const config = {
|
|
21989
22729
|
name: serviceName,
|
|
21990
|
-
|
|
21991
|
-
description: "KiCI CI/CD workflow orchestrator service",
|
|
22730
|
+
...SERVICE_TEXT.orchestrator,
|
|
21992
22731
|
executablePath,
|
|
21993
22732
|
args,
|
|
21994
22733
|
nodeBinDir: path.dirname(process.execPath),
|
|
@@ -22001,7 +22740,7 @@ Token vocabulary:
|
|
|
22001
22740
|
restartPolicy: DEFAULT_RESTART_POLICY
|
|
22002
22741
|
};
|
|
22003
22742
|
await manager.install(config);
|
|
22004
|
-
const manifestFile =
|
|
22743
|
+
const { file: manifestFile, warning: manifestWarning } = writeInstallManifest(instanceDir, {
|
|
22005
22744
|
component: "orchestrator",
|
|
22006
22745
|
name: serviceName,
|
|
22007
22746
|
platform,
|
|
@@ -22012,7 +22751,11 @@ Token vocabulary:
|
|
|
22012
22751
|
installBase: getInstallBase(platform, serviceName),
|
|
22013
22752
|
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
22014
22753
|
kiciVersion: readKiciVersion()
|
|
22754
|
+
}, {
|
|
22755
|
+
execPath: executablePath,
|
|
22756
|
+
args
|
|
22015
22757
|
});
|
|
22758
|
+
if (manifestWarning) console.warn(`Warning: ${manifestWarning}`);
|
|
22016
22759
|
try {
|
|
22017
22760
|
appendIndexEntry(kiciRoot, {
|
|
22018
22761
|
component: "orchestrator",
|
|
@@ -22229,23 +22972,135 @@ function registerOrchestratorRestart(orchestrator) {
|
|
|
22229
22972
|
});
|
|
22230
22973
|
}
|
|
22231
22974
|
//#endregion
|
|
22232
|
-
//#region src/cli/commands/
|
|
22233
|
-
|
|
22234
|
-
|
|
22235
|
-
|
|
22236
|
-
|
|
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) {
|
|
22237
23000
|
try {
|
|
22238
|
-
return fs.
|
|
22239
|
-
} 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.`);
|
|
22240
23005
|
return "";
|
|
22241
23006
|
}
|
|
22242
23007
|
}
|
|
22243
|
-
/**
|
|
22244
|
-
|
|
22245
|
-
|
|
22246
|
-
|
|
22247
|
-
|
|
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}`;
|
|
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
|
+
};
|
|
22248
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;
|
|
22249
23104
|
/** The scaler config the env file names, by path first and directory second. */
|
|
22250
23105
|
function readScalerConfig(envContent) {
|
|
22251
23106
|
return readEnvValue(envContent, "KICI_SCALER_CONFIG_PATH") ?? readEnvValue(envContent, "KICI_SCALER_CONFIG_DIR");
|
|
@@ -22266,21 +23121,14 @@ function formatConfigPaths(args) {
|
|
|
22266
23121
|
if (args.platform === "compose") lines.push(`Compose file: ${composeFilePath(args.envFilePath, args.serviceName)}`);
|
|
22267
23122
|
return lines;
|
|
22268
23123
|
}
|
|
22269
|
-
/**
|
|
22270
|
-
|
|
22271
|
-
|
|
22272
|
-
|
|
22273
|
-
|
|
22274
|
-
const res = await fetch(`http://localhost:${port}/health`, { signal: controller.signal });
|
|
22275
|
-
clearTimeout(timeout);
|
|
22276
|
-
if (!res.ok) return null;
|
|
22277
|
-
return await res.json();
|
|
22278
|
-
} catch {
|
|
22279
|
-
return null;
|
|
22280
|
-
}
|
|
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";
|
|
22281
23129
|
}
|
|
22282
23130
|
/** Format the status output as a readable table. */
|
|
22283
|
-
function formatStatus$1(serviceStatus, health, serviceName) {
|
|
23131
|
+
function formatStatus$1(serviceStatus, health, readiness, serviceName) {
|
|
22284
23132
|
const lines = [];
|
|
22285
23133
|
lines.push(`Service: ${serviceName}`);
|
|
22286
23134
|
lines.push(`State: ${serviceStatus.state}`);
|
|
@@ -22288,18 +23136,11 @@ function formatStatus$1(serviceStatus, health, serviceName) {
|
|
|
22288
23136
|
if (serviceStatus.uptime != null) lines.push(`Uptime: ${formatUptime(serviceStatus.uptime)}`);
|
|
22289
23137
|
if (serviceStatus.startedAt) lines.push(`Started: ${serviceStatus.startedAt}`);
|
|
22290
23138
|
if (health) {
|
|
22291
|
-
|
|
22292
|
-
|
|
22293
|
-
|
|
22294
|
-
|
|
22295
|
-
|
|
22296
|
-
if (health.platformRelay) lines.push(`Platform relay: ${health.platformRelay}`);
|
|
22297
|
-
if (health.agents != null) lines.push(`Agents: ${health.agents}`);
|
|
22298
|
-
if (health.scaler) {
|
|
22299
|
-
const s = health.scaler;
|
|
22300
|
-
lines.push(`Scaler: ${s.type ?? "none"} (warm: ${s.warm ?? 0}, max: ${s.max ?? 0})`);
|
|
22301
|
-
}
|
|
22302
|
-
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));
|
|
22303
23144
|
} else if (serviceStatus.state === "running") {
|
|
22304
23145
|
lines.push("");
|
|
22305
23146
|
lines.push("(Could not reach health API)");
|
|
@@ -22307,12 +23148,13 @@ function formatStatus$1(serviceStatus, health, serviceName) {
|
|
|
22307
23148
|
return lines.join("\n");
|
|
22308
23149
|
}
|
|
22309
23150
|
/** Build JSON output combining service + health data. */
|
|
22310
|
-
function buildJsonOutput$1(serviceStatus, health, serviceName, configPaths) {
|
|
23151
|
+
function buildJsonOutput$1(serviceStatus, health, readiness, serviceName, configPaths) {
|
|
22311
23152
|
return {
|
|
22312
23153
|
service: serviceName,
|
|
22313
23154
|
...serviceStatus,
|
|
22314
23155
|
configPaths,
|
|
22315
|
-
health: health
|
|
23156
|
+
health: health ? hideBuildCommit(health) : void 0,
|
|
23157
|
+
readiness: readiness ?? void 0
|
|
22316
23158
|
};
|
|
22317
23159
|
}
|
|
22318
23160
|
function registerStatusCommand(parent) {
|
|
@@ -22351,14 +23193,21 @@ function registerStatusCommand(parent) {
|
|
|
22351
23193
|
envContent
|
|
22352
23194
|
});
|
|
22353
23195
|
let health = null;
|
|
22354
|
-
|
|
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
|
+
}
|
|
22355
23204
|
const jsonConfigPaths = { envFile: config.envFilePath };
|
|
22356
23205
|
const scalerPath = readScalerConfig(envContent);
|
|
22357
23206
|
if (scalerPath) jsonConfigPaths.scalerConfig = scalerPath;
|
|
22358
23207
|
if (installPlatform === "compose") jsonConfigPaths.composeFile = composeFilePath(config.envFilePath, config.name);
|
|
22359
|
-
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));
|
|
22360
23209
|
else {
|
|
22361
|
-
console.log(formatStatus$1(serviceStatus, health, config.name));
|
|
23210
|
+
console.log(formatStatus$1(serviceStatus, health, readiness, config.name));
|
|
22362
23211
|
console.log("");
|
|
22363
23212
|
console.log(configPathLines.join("\n"));
|
|
22364
23213
|
}
|
|
@@ -22525,6 +23374,26 @@ function resolveDatabaseUrl$2(envFilePath, readFile) {
|
|
|
22525
23374
|
return url;
|
|
22526
23375
|
}
|
|
22527
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
|
|
22528
23397
|
//#region src/agent-packaging/node-binary.ts
|
|
22529
23398
|
/**
|
|
22530
23399
|
* Fetch a Node dist URL, retrying transient failures. The runtime download is a
|
|
@@ -22583,12 +23452,7 @@ async function downloadNodeBinary(opts) {
|
|
|
22583
23452
|
const archivePath = path.join(prefixDir, filename);
|
|
22584
23453
|
writeFileSync(archivePath, buf);
|
|
22585
23454
|
const inner = `node-v${opts.version}-${nodeOs}-${nodeArch}`;
|
|
22586
|
-
const members =
|
|
22587
|
-
`${inner}/bin/node`,
|
|
22588
|
-
`${inner}/bin/npm`,
|
|
22589
|
-
`${inner}/bin/npx`,
|
|
22590
|
-
`${inner}/lib/node_modules/npm`
|
|
22591
|
-
];
|
|
23455
|
+
const members = NODE_RUNTIME_MEMBERS.map((member) => `${inner}/${member}`);
|
|
22592
23456
|
const out = $.sync({
|
|
22593
23457
|
nothrow: true,
|
|
22594
23458
|
stdio: "pipe"
|
|
@@ -22807,7 +23671,7 @@ init_manifest();
|
|
|
22807
23671
|
const ALL_PLATFORMS = AgentPlatform.options;
|
|
22808
23672
|
/** The orchestrator's own version (single-version invariant) = the packaged agent version. */
|
|
22809
23673
|
function resolveKiciVersion() {
|
|
22810
|
-
return "0.
|
|
23674
|
+
return "0.12.0";
|
|
22811
23675
|
}
|
|
22812
23676
|
/** Parse the --platform value: default set | single | CSV | `all`. */
|
|
22813
23677
|
function parsePlatforms(raw) {
|
|
@@ -22991,6 +23855,17 @@ function registerOrchestratorServiceCommands(program, getClient, tryGetClient) {
|
|
|
22991
23855
|
registerOrchestratorDrain(orchestrator, getClient);
|
|
22992
23856
|
}
|
|
22993
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
|
|
22994
23869
|
//#region src/cli/wizard/agent-wizard.ts
|
|
22995
23870
|
var agent_wizard_exports = /* @__PURE__ */ __exportAll({ runAgentWizard: () => runAgentWizard });
|
|
22996
23871
|
/**
|
|
@@ -23011,13 +23886,13 @@ async function runAgentWizard() {
|
|
|
23011
23886
|
const agentToken = await promptSecret("Agent authentication token:");
|
|
23012
23887
|
console.log("");
|
|
23013
23888
|
const labels = (await input({
|
|
23014
|
-
message: "Agent labels (comma-separated
|
|
23889
|
+
message: "Agent labels (comma-separated; empty = only the kici:* labels every agent reports):",
|
|
23015
23890
|
default: ""
|
|
23016
23891
|
})).split(",").map((l) => l.trim()).filter(Boolean);
|
|
23017
23892
|
console.log("");
|
|
23018
23893
|
console.log("Configuration complete. Summary:");
|
|
23019
23894
|
console.log(` Orchestrator: ${orchestratorUrl}`);
|
|
23020
|
-
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)"}`);
|
|
23021
23896
|
console.log("");
|
|
23022
23897
|
return {
|
|
23023
23898
|
orchestratorUrl,
|
|
@@ -23031,13 +23906,26 @@ var init_agent_wizard = __esmMin((() => {
|
|
|
23031
23906
|
//#endregion
|
|
23032
23907
|
//#region src/cli/commands/agent-service/install.ts
|
|
23033
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
|
+
}
|
|
23034
23919
|
function registerAgentInstall(agent) {
|
|
23035
|
-
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) => {
|
|
23036
23921
|
try {
|
|
23922
|
+
const resolveModule = typeof import.meta.resolve === "function" ? (specifier) => import.meta.resolve(specifier) : void 0;
|
|
23923
|
+
assertServerResolvable("agent", opts.binary, resolveModule);
|
|
23037
23924
|
if (opts.wizard && opts.envFile) {
|
|
23038
23925
|
console.error("Error: Cannot use --wizard with --env-file");
|
|
23039
23926
|
process.exit(1);
|
|
23040
23927
|
}
|
|
23928
|
+
const port = parsePort(opts.port);
|
|
23041
23929
|
const platform = detectPlatform(opts.platform);
|
|
23042
23930
|
const userLevel = resolveUserLevel(opts);
|
|
23043
23931
|
const serviceName = opts.name;
|
|
@@ -23063,14 +23951,17 @@ function registerAgentInstall(agent) {
|
|
|
23063
23951
|
fs.mkdirSync(configDir, { recursive: true });
|
|
23064
23952
|
fs.mkdirSync(logDir, { recursive: true });
|
|
23065
23953
|
const envFilePath = path.join(configDir, `${serviceName}.env`);
|
|
23954
|
+
if (platform === "windows" && !userLevel) restrictEnvFileAccess(envFilePath);
|
|
23066
23955
|
if (opts.wizard) {
|
|
23067
23956
|
const { runAgentWizard } = await Promise.resolve().then(() => (init_agent_wizard(), agent_wizard_exports));
|
|
23068
23957
|
const wizardConfig = await runAgentWizard();
|
|
23069
|
-
|
|
23070
|
-
|
|
23071
|
-
|
|
23072
|
-
|
|
23073
|
-
|
|
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);
|
|
23074
23965
|
console.log(`Wrote wizard configuration to ${envFilePath}`);
|
|
23075
23966
|
} else if (opts.envFile) {
|
|
23076
23967
|
const source = path.resolve(opts.envFile);
|
|
@@ -23081,15 +23972,17 @@ function registerAgentInstall(agent) {
|
|
|
23081
23972
|
copyFileSecurelySync(source, envFilePath, 384);
|
|
23082
23973
|
console.log(`Copied env file to ${envFilePath}`);
|
|
23083
23974
|
} else if (!fs.existsSync(envFilePath)) {
|
|
23084
|
-
|
|
23085
|
-
|
|
23086
|
-
|
|
23087
|
-
|
|
23088
|
-
|
|
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);
|
|
23089
23982
|
console.log(`Created env file at ${envFilePath}`);
|
|
23090
23983
|
}
|
|
23091
23984
|
if (fs.existsSync(envFilePath)) fs.chmodSync(envFilePath, 384);
|
|
23092
|
-
const entryScript = opts.binary ? void 0 :
|
|
23985
|
+
const entryScript = opts.binary ? void 0 : resolveServerScript("@kici-dev/agent/server", "agent", resolveModule);
|
|
23093
23986
|
const { executablePath, args } = resolveServiceExecutable({
|
|
23094
23987
|
binary: opts.binary ? path.resolve(opts.binary) : void 0,
|
|
23095
23988
|
nodePath: process.execPath,
|
|
@@ -23097,8 +23990,7 @@ function registerAgentInstall(agent) {
|
|
|
23097
23990
|
});
|
|
23098
23991
|
const config = {
|
|
23099
23992
|
name: serviceName,
|
|
23100
|
-
|
|
23101
|
-
description: "KiCI CI/CD workflow execution agent service",
|
|
23993
|
+
...SERVICE_TEXT.agent,
|
|
23102
23994
|
executablePath,
|
|
23103
23995
|
args,
|
|
23104
23996
|
nodeBinDir: path.dirname(process.execPath),
|
|
@@ -23110,7 +24002,7 @@ function registerAgentInstall(agent) {
|
|
|
23110
24002
|
restartPolicy: DEFAULT_RESTART_POLICY
|
|
23111
24003
|
};
|
|
23112
24004
|
await manager.install(config);
|
|
23113
|
-
const manifestFile =
|
|
24005
|
+
const { file: manifestFile, warning: manifestWarning } = writeInstallManifest(instanceDir, {
|
|
23114
24006
|
component: "agent",
|
|
23115
24007
|
name: serviceName,
|
|
23116
24008
|
platform,
|
|
@@ -23121,7 +24013,11 @@ function registerAgentInstall(agent) {
|
|
|
23121
24013
|
installBase: getInstallBase(platform, serviceName),
|
|
23122
24014
|
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
23123
24015
|
kiciVersion: readKiciVersion()
|
|
24016
|
+
}, {
|
|
24017
|
+
execPath: executablePath,
|
|
24018
|
+
args
|
|
23124
24019
|
});
|
|
24020
|
+
if (manifestWarning) console.warn(`Warning: ${manifestWarning}`);
|
|
23125
24021
|
try {
|
|
23126
24022
|
appendIndexEntry(kiciRoot, {
|
|
23127
24023
|
component: "agent",
|
|
@@ -23333,37 +24229,8 @@ function registerAgentRestart(agent) {
|
|
|
23333
24229
|
//#endregion
|
|
23334
24230
|
//#region src/cli/commands/agent-service/status.ts
|
|
23335
24231
|
init_service();
|
|
23336
|
-
/**
|
|
23337
|
-
|
|
23338
|
-
if (!fs.existsSync(envFilePath)) return 4001;
|
|
23339
|
-
try {
|
|
23340
|
-
const content = fs.readFileSync(envFilePath, "utf-8");
|
|
23341
|
-
for (const line of content.split("\n")) {
|
|
23342
|
-
const trimmed = line.trim();
|
|
23343
|
-
if (trimmed.startsWith("#") || !trimmed.includes("=")) continue;
|
|
23344
|
-
const [key, ...rest] = trimmed.split("=");
|
|
23345
|
-
if (key?.trim() === "KICI_AGENT_PORT" || key?.trim() === "KICI_PORT") {
|
|
23346
|
-
const val = rest.join("=").trim().replace(/^["']|["']$/g, "");
|
|
23347
|
-
const parsed = parseInt(val, 10);
|
|
23348
|
-
if (!isNaN(parsed)) return parsed;
|
|
23349
|
-
}
|
|
23350
|
-
}
|
|
23351
|
-
} catch {}
|
|
23352
|
-
return 4001;
|
|
23353
|
-
}
|
|
23354
|
-
/** Query agent health API. */
|
|
23355
|
-
async function queryHealth(port) {
|
|
23356
|
-
try {
|
|
23357
|
-
const controller = new AbortController();
|
|
23358
|
-
const timeout = setTimeout(() => controller.abort(), 3e3);
|
|
23359
|
-
const res = await fetch(`http://localhost:${port}/health`, { signal: controller.signal });
|
|
23360
|
-
clearTimeout(timeout);
|
|
23361
|
-
if (!res.ok) return null;
|
|
23362
|
-
return await res.json();
|
|
23363
|
-
} catch {
|
|
23364
|
-
return null;
|
|
23365
|
-
}
|
|
23366
|
-
}
|
|
24232
|
+
/** Column the values of the agent section start at. */
|
|
24233
|
+
const VALUE_COLUMN = 14;
|
|
23367
24234
|
function formatStatus(serviceStatus, health, serviceName) {
|
|
23368
24235
|
const lines = [];
|
|
23369
24236
|
lines.push(`Service: ${serviceName}`);
|
|
@@ -23372,13 +24239,12 @@ function formatStatus(serviceStatus, health, serviceName) {
|
|
|
23372
24239
|
if (serviceStatus.uptime != null) lines.push(`Uptime: ${formatUptime(serviceStatus.uptime)}`);
|
|
23373
24240
|
if (serviceStatus.startedAt) lines.push(`Started: ${serviceStatus.startedAt}`);
|
|
23374
24241
|
if (health) {
|
|
23375
|
-
|
|
23376
|
-
|
|
23377
|
-
if (
|
|
23378
|
-
|
|
23379
|
-
|
|
23380
|
-
|
|
23381
|
-
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));
|
|
23382
24248
|
} else if (serviceStatus.state === "running") {
|
|
23383
24249
|
lines.push("");
|
|
23384
24250
|
lines.push("(Could not reach agent health API)");
|
|
@@ -23389,7 +24255,7 @@ function buildJsonOutput(serviceStatus, health, serviceName) {
|
|
|
23389
24255
|
return {
|
|
23390
24256
|
service: serviceName,
|
|
23391
24257
|
...serviceStatus,
|
|
23392
|
-
health: health
|
|
24258
|
+
health: health ? hideBuildCommit(health) : void 0
|
|
23393
24259
|
};
|
|
23394
24260
|
}
|
|
23395
24261
|
function registerAgentStatusCommand(parent) {
|
|
@@ -23421,7 +24287,7 @@ function registerAgentStatusCommand(parent) {
|
|
|
23421
24287
|
};
|
|
23422
24288
|
const serviceStatus = await manager.status(config);
|
|
23423
24289
|
let health = null;
|
|
23424
|
-
if (serviceStatus.state === "running") health = await
|
|
24290
|
+
if (serviceStatus.state === "running") health = await fetchLocalJson(readLocalEndpoint(readEnvContent(config.envFilePath), AGENT_DEFAULT_PORT), "/health");
|
|
23425
24291
|
if (opts.json) console.log(JSON.stringify(buildJsonOutput(serviceStatus, health, config.name), null, 2));
|
|
23426
24292
|
else console.log(formatStatus(serviceStatus, health, config.name));
|
|
23427
24293
|
} catch (err) {
|
|
@@ -26258,7 +27124,7 @@ function registerClusterNameCommands(program, getClient) {
|
|
|
26258
27124
|
* (`cluster_meta.cluster_id`) and the durable S3 sentinel
|
|
26259
27125
|
* (`<prefix>/.kici-cluster-id`).
|
|
26260
27126
|
*
|
|
26261
|
-
* Used by `kici-admin cluster reconcile-identity
|
|
27127
|
+
* Used by `kici-admin cluster reconcile-identity`, which a deploy can run as a
|
|
26262
27128
|
* pre-orchestrator-start self-heal step. Talks DB + S3 directly (never the
|
|
26263
27129
|
* orchestrator HTTP admin API) so it works while the orchestrator process is
|
|
26264
27130
|
* down — which is exactly when a "Cluster identity mismatch" boot failure needs
|
|
@@ -26455,9 +27321,8 @@ function registerClusterCommands(program) {
|
|
|
26455
27321
|
*
|
|
26456
27322
|
* Direct-DB mode: activated when `--database-url` is passed (or
|
|
26457
27323
|
* KICI_DATABASE_URL / DATABASE_URL is set). Opens its own pool and runs the
|
|
26458
|
-
* SQL directly.
|
|
26459
|
-
*
|
|
26460
|
-
* 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.
|
|
26461
27326
|
*/
|
|
26462
27327
|
function resolveDirectDbUrl$5(explicit) {
|
|
26463
27328
|
return explicit ?? process.env.KICI_DATABASE_URL ?? null;
|
|
@@ -27489,7 +28354,7 @@ function registerEventDlqCommands(program, getClient) {
|
|
|
27489
28354
|
* Contract:
|
|
27490
28355
|
* - tenant column: `routing_key` (denormalized in migration 006)
|
|
27491
28356
|
* - partition column: `created_at`
|
|
27492
|
-
* - warm TTL: 30 days
|
|
28357
|
+
* - warm TTL: 30 days
|
|
27493
28358
|
* - eligibility: created_at < cutoff (steps are append-only and have no
|
|
27494
28359
|
* terminal-status predicate — there is no UPDATE statement against
|
|
27495
28360
|
* `execution_steps` in the orchestrator codebase, so age alone is
|
|
@@ -27647,7 +28512,7 @@ var init_execution_steps = __esmMin((() => {
|
|
|
27647
28512
|
* Contract:
|
|
27648
28513
|
* - tenant column: `routing_key` (denormalized in migration 006)
|
|
27649
28514
|
* - partition column: `created_at`
|
|
27650
|
-
* - warm TTL: 30 days
|
|
28515
|
+
* - warm TTL: 30 days
|
|
27651
28516
|
* - eligibility: terminal job status AND no live steps remain for this
|
|
27652
28517
|
* (run, job)
|
|
27653
28518
|
*
|
|
@@ -27822,7 +28687,7 @@ var init_execution_jobs = __esmMin((() => {
|
|
|
27822
28687
|
* Contract:
|
|
27823
28688
|
* - tenant column: `routing_key`
|
|
27824
28689
|
* - partition column: `created_at`
|
|
27825
|
-
* - warm TTL: 30 days
|
|
28690
|
+
* - warm TTL: 30 days
|
|
27826
28691
|
* - eligibility: terminal run status AND no live jobs reference this run
|
|
27827
28692
|
*
|
|
27828
28693
|
* Order: registered LAST in the orchestrator cold-store. Within one
|
|
@@ -27940,7 +28805,7 @@ var init_execution_runs = __esmMin((() => {
|
|
|
27940
28805
|
return row.created_at;
|
|
27941
28806
|
}
|
|
27942
28807
|
/**
|
|
27943
|
-
*
|
|
28808
|
+
* Natural-key extractor used by `replayRow` so callers
|
|
27944
28809
|
* (the rerun pipeline) can locate the chunk holding a given UUID
|
|
27945
28810
|
* `run_id` without knowing the internal SERIAL `id` returned by
|
|
27946
28811
|
* `rowId()`.
|
|
@@ -27949,10 +28814,10 @@ var init_execution_runs = __esmMin((() => {
|
|
|
27949
28814
|
return row.run_id;
|
|
27950
28815
|
}
|
|
27951
28816
|
/**
|
|
27952
|
-
*
|
|
28817
|
+
* Replay a chunk's `execution_runs` rows back into
|
|
27953
28818
|
* Orchestrator PG. Mirrors the Platform-side replayInsert; the
|
|
27954
28819
|
* audit row goes to `access_log` (not `audit_log`) because the
|
|
27955
|
-
* orchestrator's audit surface is access_log
|
|
28820
|
+
* orchestrator's audit surface is access_log.
|
|
27956
28821
|
*
|
|
27957
28822
|
* Idempotent on duplicate replays via `ON CONFLICT (run_id) DO
|
|
27958
28823
|
* NOTHING`. Decrements `cold_store_chunk_counts` (floored at 0).
|
|
@@ -28051,8 +28916,8 @@ var init_execution_runs = __esmMin((() => {
|
|
|
28051
28916
|
* Contract:
|
|
28052
28917
|
* - tenant column: `routing_key` (NULL → synthetic `__orchestrator__`)
|
|
28053
28918
|
* - partition column: `timestamp`
|
|
28054
|
-
* - warm TTL: 90 days (longer window than other audit tables —
|
|
28055
|
-
*
|
|
28919
|
+
* - warm TTL: 90 days (longer window than other audit tables — volume is
|
|
28920
|
+
* low, forensic value is high)
|
|
28056
28921
|
*
|
|
28057
28922
|
* Synthetic tenant: rows without `routing_key` (e.g. orchestrator-level
|
|
28058
28923
|
* key rotations done before any source is bound) collapse to a single
|
|
@@ -28185,7 +29050,7 @@ var init_secret_audit_log = __esmMin((() => {
|
|
|
28185
29050
|
return row.timestamp;
|
|
28186
29051
|
}
|
|
28187
29052
|
/**
|
|
28188
|
-
*
|
|
29053
|
+
* Per-row cold-retention TTL. Sampled `resolve` /
|
|
28189
29054
|
* `resolve_named` rows get 180d cold; mutations get `'forever'`. The
|
|
28190
29055
|
* denied-outcome override promotes to 730d (forensic).
|
|
28191
29056
|
*
|
|
@@ -28299,7 +29164,7 @@ var init_secret_audit_log = __esmMin((() => {
|
|
|
28299
29164
|
* Contract:
|
|
28300
29165
|
* - tenant column: `org_id` (NULL → synthetic `__orchestrator__`)
|
|
28301
29166
|
* - partition column: `created_at`
|
|
28302
|
-
* - warm TTL: 30 days
|
|
29167
|
+
* - warm TTL: 30 days
|
|
28303
29168
|
*
|
|
28304
29169
|
* Replaces the previous 90-day `expires_at`-based hard delete (removed
|
|
28305
29170
|
* by migration 007). Rows older than 30 days now live in S3
|
|
@@ -28440,7 +29305,7 @@ var init_access_log = __esmMin((() => {
|
|
|
28440
29305
|
return row.created_at;
|
|
28441
29306
|
}
|
|
28442
29307
|
/**
|
|
28443
|
-
*
|
|
29308
|
+
* Per-row cold-retention TTL. Mirrors the warm-side override
|
|
28444
29309
|
* layering: outcome=denied/error → 730d (forensic), platform_operator →
|
|
28445
29310
|
* `'forever'` (compliance), otherwise per-action map. The framework
|
|
28446
29311
|
* groups rows by `coldDaysToBucket(coldTtlDays(row))` at archive time.
|
|
@@ -28545,27 +29410,21 @@ var init_access_log = __esmMin((() => {
|
|
|
28545
29410
|
//#endregion
|
|
28546
29411
|
//#region src/cold-store/tables/event-log.ts
|
|
28547
29412
|
/**
|
|
28548
|
-
* `event_log` cold-store adapter (Orchestrator side)
|
|
28549
|
-
*
|
|
28550
|
-
* §2 row 6
|
|
28551
|
-
* and §10.
|
|
29413
|
+
* `event_log` cold-store adapter (Orchestrator side).
|
|
28552
29414
|
*
|
|
28553
29415
|
* Contract:
|
|
28554
29416
|
* - tenant column: `routing_key` (NOT NULL on this side — no
|
|
28555
29417
|
* synthetic-tenant fallback like access_log / secret_audit_log)
|
|
28556
29418
|
* - partition column: `received_at`
|
|
28557
|
-
* - warm TTL: 30 days (
|
|
28558
|
-
* `packages/orchestrator/src/webhook/event-log.ts:cleanup` +
|
|
28559
|
-
* `packages/orchestrator/src/queue/cleanup.ts:runCleanup` step 4)
|
|
29419
|
+
* - warm TTL: 30 days (rows are archived, not hard-deleted)
|
|
28560
29420
|
*
|
|
28561
29421
|
* Per-row payload retention: rows on this table carry a `payload_key`
|
|
28562
29422
|
* pointing at a gzipped webhook body in object storage (LogStorage).
|
|
28563
29423
|
* Cold-store packages the row metadata — including `payload_key` —
|
|
28564
29424
|
* but does NOT touch the body the key points to. The body remains in
|
|
28565
29425
|
* S3 indefinitely so the dashboard delivery-detail page resolves
|
|
28566
|
-
* payload reads identically for hot and cold rows.
|
|
28567
|
-
*
|
|
28568
|
-
* 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.
|
|
28569
29428
|
*
|
|
28570
29429
|
* Recursive write: archiving event_log rows writes one access_log
|
|
28571
29430
|
* audit row per chunk (the orchestrator's audit surface). The new
|
|
@@ -28707,17 +29566,15 @@ var init_event_log = __esmMin((() => {
|
|
|
28707
29566
|
/**
|
|
28708
29567
|
* Orchestrator-side cold-store.
|
|
28709
29568
|
*
|
|
28710
|
-
* Extends BaseColdStore with orchestrator-specific table adapters
|
|
28711
|
-
*
|
|
28712
|
-
*
|
|
28713
|
-
* 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.
|
|
28714
29572
|
*/
|
|
28715
29573
|
/**
|
|
28716
29574
|
* Build the orchestrator cold-store config from env vars. Reads
|
|
28717
|
-
* `KICI_COLD_STORE_*` vars mirrored with the Platform side.
|
|
28718
|
-
*
|
|
28719
|
-
*
|
|
28720
|
-
* 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.
|
|
28721
29578
|
*/
|
|
28722
29579
|
function readOrchestratorColdStoreConfig(env = process.env) {
|
|
28723
29580
|
const enabled = env.KICI_COLD_STORE_ENABLED === "true";
|
|
@@ -28810,7 +29667,7 @@ var init_orchestrator_cold_store = __esmMin((() => {
|
|
|
28810
29667
|
this.registerAdapter(new EventLogAdapter(deps.kdb, deps.instanceId, { overrides: deps.config.tables.event_log ?? deps.config.tables["event_log"] }));
|
|
28811
29668
|
}
|
|
28812
29669
|
/**
|
|
28813
|
-
*
|
|
29670
|
+
* Query the orchestrator's `cold_store_chunks` table for
|
|
28814
29671
|
* chunks past their per-row cold-retention horizon. See the matching
|
|
28815
29672
|
* Platform-side method for the full rationale.
|
|
28816
29673
|
*/
|
|
@@ -28843,7 +29700,7 @@ var init_orchestrator_cold_store = __esmMin((() => {
|
|
|
28843
29700
|
}));
|
|
28844
29701
|
}
|
|
28845
29702
|
/**
|
|
28846
|
-
*
|
|
29703
|
+
* Per-chunk advisory lock keyed on
|
|
28847
29704
|
* `hashtext('cold-store-purge|orchestrator|<table>|<chunkId>')`.
|
|
28848
29705
|
*/
|
|
28849
29706
|
async withPurgeLock(args, fn) {
|
|
@@ -28866,8 +29723,7 @@ var init_orchestrator_cold_store = __esmMin((() => {
|
|
|
28866
29723
|
/**
|
|
28867
29724
|
* Implementations for `kici-admin cold-store` subcommands.
|
|
28868
29725
|
*
|
|
28869
|
-
* Mirrors the Platform-side implementation
|
|
28870
|
-
* `packages/platform/src/admin/cold-store-commands.ts` — same shape,
|
|
29726
|
+
* Mirrors the Platform-side implementation — same shape,
|
|
28871
29727
|
* just builds an `OrchestratorColdStore` against the orchestrator
|
|
28872
29728
|
* Postgres + S3 instead of a `PlatformColdStore`.
|
|
28873
29729
|
*
|
|
@@ -29737,7 +30593,7 @@ function registerCacheCommands(program, deps = {
|
|
|
29737
30593
|
*
|
|
29738
30594
|
* A leaf module on purpose: it imports nothing, so an out-of-package consumer
|
|
29739
30595
|
* can read the constant without pulling the orchestrator runtime graph in with
|
|
29740
|
-
* it.
|
|
30596
|
+
* it. A NetworkManager drift watchdog derives its
|
|
29741
30597
|
* expected-pattern set from here rather than re-typing the patterns — a second
|
|
29742
30598
|
* copy desyncs silently the moment a pattern is added. `host-network.ts`
|
|
29743
30599
|
* re-exports it and renders `NM_CONF_CONTENT` from it, so the drop-in and the
|
|
@@ -29805,9 +30661,9 @@ function bridgePrefix(bridgeName) {
|
|
|
29805
30661
|
* serializes them.
|
|
29806
30662
|
*
|
|
29807
30663
|
* The command list NEVER deletes the table. It is a self-heal — `ensureHostReady`
|
|
29808
|
-
* runs it whenever `verifyBridge` reports unhealthy,
|
|
29809
|
-
*
|
|
29810
|
-
*
|
|
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
|
|
29811
30667
|
* cloud-metadata reach, until it was destroyed. Instead the baseline lives in
|
|
29812
30668
|
* its own regular chain (`baseline`, see {@link BASELINE_CHAIN}) that is flushed
|
|
29813
30669
|
* and refilled, and the hooked `forward` chain keeps its per-VM rules
|
|
@@ -29937,10 +30793,9 @@ const NM_CONF_CONTENT = [
|
|
|
29937
30793
|
"#",
|
|
29938
30794
|
"# The `+=` append operator is mandatory: NM merges conf.d drop-ins last-wins",
|
|
29939
30795
|
"# per key, so a bare `unmanaged-devices=` here would be silently clobbered by",
|
|
29940
|
-
"# (or would silently clobber) any other drop-in's unmanaged-devices line
|
|
29941
|
-
"#
|
|
29942
|
-
"#
|
|
29943
|
-
"# .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.",
|
|
29944
30799
|
"[keyfile]",
|
|
29945
30800
|
...FIRECRACKER_NET_INTERFACES.map((p) => `unmanaged-devices+=interface-name:${p}`),
|
|
29946
30801
|
""
|
|
@@ -30128,6 +30983,7 @@ async function verifyBridge(cfg, opts = {}) {
|
|
|
30128
30983
|
} catch {
|
|
30129
30984
|
misses.push(`could not read chain ip ${cfg.table} forward`);
|
|
30130
30985
|
}
|
|
30986
|
+
const { natPresent, baselineRulesPresent } = await checkSubnetRules(cfg, runner, misses);
|
|
30131
30987
|
try {
|
|
30132
30988
|
const { stdout } = await runner({
|
|
30133
30989
|
bin: "ip",
|
|
@@ -30146,7 +31002,7 @@ async function verifyBridge(cfg, opts = {}) {
|
|
|
30146
31002
|
} catch {
|
|
30147
31003
|
misses.push(`could not read enslaved links on ${cfg.bridgeName}`);
|
|
30148
31004
|
}
|
|
30149
|
-
const healthy = bridgeExists && bridgeUp && addrPresent && tablePresent && baselineChainPresent && tapIsolationPresent;
|
|
31005
|
+
const healthy = bridgeExists && bridgeUp && addrPresent && tablePresent && baselineChainPresent && natPresent && baselineRulesPresent && tapIsolationPresent;
|
|
30150
31006
|
return {
|
|
30151
31007
|
bridgeName: cfg.bridgeName,
|
|
30152
31008
|
bridgeExists,
|
|
@@ -30154,6 +31010,8 @@ async function verifyBridge(cfg, opts = {}) {
|
|
|
30154
31010
|
addrPresent,
|
|
30155
31011
|
tablePresent,
|
|
30156
31012
|
baselineChainPresent,
|
|
31013
|
+
natPresent,
|
|
31014
|
+
baselineRulesPresent,
|
|
30157
31015
|
tapIsolationPresent,
|
|
30158
31016
|
healthy,
|
|
30159
31017
|
detail: healthy ? "healthy" : misses.join("; ")
|
|
@@ -30183,6 +31041,146 @@ function forwardEndsInBaselineJump(nftJson) {
|
|
|
30183
31041
|
});
|
|
30184
31042
|
}
|
|
30185
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
|
+
/**
|
|
30186
31184
|
* Names of the kici-* TAPs enslaved to the bridge that are NOT port-isolated,
|
|
30187
31185
|
* read from `ip -d -j link show master <bridge>`.
|
|
30188
31186
|
*
|
|
@@ -30348,7 +31346,7 @@ function registerProvision(group) {
|
|
|
30348
31346
|
//#endregion
|
|
30349
31347
|
//#region src/cli/commands/firecracker/verify.ts
|
|
30350
31348
|
function registerVerify(group) {
|
|
30351
|
-
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) => {
|
|
30352
31350
|
const cfg = optionsToConfig(opts);
|
|
30353
31351
|
const h = await verifyBridge(cfg, { requireSudo: opts.sudo });
|
|
30354
31352
|
if (h.healthy) {
|
|
@@ -30427,6 +31425,57 @@ function isNodeError(err) {
|
|
|
30427
31425
|
return err instanceof Error && "code" in err;
|
|
30428
31426
|
}
|
|
30429
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
|
+
}
|
|
30430
31479
|
/**
|
|
30431
31480
|
* Zod schema for a single resource spec (cpus + memory).
|
|
30432
31481
|
*
|
|
@@ -30569,6 +31618,12 @@ const scalerEntrySchema = z.object({
|
|
|
30569
31618
|
"auto"
|
|
30570
31619
|
]).default("auto"),
|
|
30571
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
|
+
*/
|
|
30572
31627
|
extraHosts: z.array(z.string()).optional(),
|
|
30573
31628
|
networkIsolation: z.boolean().default(true),
|
|
30574
31629
|
warmPool: warmPoolSchema,
|
|
@@ -30714,6 +31769,17 @@ const scalerEntrySchema = z.object({
|
|
|
30714
31769
|
message: `Firecracker scaler "${data.name}" requires a 'gid' field`,
|
|
30715
31770
|
path: ["gid"]
|
|
30716
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
|
+
});
|
|
30717
31783
|
data.labelSets.forEach((ls, i) => {
|
|
30718
31784
|
if (!ls.rootfsPath) ctx.addIssue({
|
|
30719
31785
|
code: z.ZodIssueCode.custom,
|
|
@@ -31579,19 +32645,12 @@ const POSIX_ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
|
31579
32645
|
* Interface-name pattern for per-VM TAP devices.
|
|
31580
32646
|
* Matches what {@link generateTapName} produces — `kici-` plus the LAST 8
|
|
31581
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.
|
|
31582
32652
|
*/
|
|
31583
32653
|
const VM_TAP_PATTERN = /^kici-[0-9a-f]{8}$/;
|
|
31584
|
-
/**
|
|
31585
|
-
* Infrastructure interfaces that share the `kici-` prefix but must NEVER be
|
|
31586
|
-
* deleted by the orphan sweep. Bridges (kici-br0/kici-br1) carry all VM
|
|
31587
|
-
* traffic; kici-m01 is the staging metadata interface. Losing any of these
|
|
31588
|
-
* kills all running VMs.
|
|
31589
|
-
*/
|
|
31590
|
-
const PROTECTED_INTERFACES = [
|
|
31591
|
-
"kici-br0",
|
|
31592
|
-
"kici-br1",
|
|
31593
|
-
"kici-m01"
|
|
31594
|
-
];
|
|
31595
32654
|
/** Convert a dotted IPv4 netmask ('255.255.255.0') to a CIDR prefix length (24). */
|
|
31596
32655
|
function netmaskToPrefix(netmask) {
|
|
31597
32656
|
return netmask.split(".").map((o) => Number(o).toString(2).padStart(8, "0")).join("").split("1").length - 1;
|
|
@@ -31626,6 +32685,8 @@ var FirecrackerScalerBackend = class FirecrackerScalerBackend {
|
|
|
31626
32685
|
bridgeName;
|
|
31627
32686
|
cidr;
|
|
31628
32687
|
gateway;
|
|
32688
|
+
/** The MMDS `kici-extra-hosts` value, or undefined when the scaler maps no host. */
|
|
32689
|
+
guestExtraHosts;
|
|
31629
32690
|
/** Host services the orchestrator directed agents at, as `hostAccess` entries. */
|
|
31630
32691
|
hostServices;
|
|
31631
32692
|
netmask;
|
|
@@ -31660,6 +32721,7 @@ var FirecrackerScalerBackend = class FirecrackerScalerBackend {
|
|
|
31660
32721
|
this.bridgeName = options.bridgeName;
|
|
31661
32722
|
this.cidr = options.cidr;
|
|
31662
32723
|
this.gateway = options.gateway;
|
|
32724
|
+
this.guestExtraHosts = renderGuestExtraHosts(options.extraHosts, options.gateway);
|
|
31663
32725
|
this.hostServices = options.hostServices;
|
|
31664
32726
|
this.netmask = options.netmask;
|
|
31665
32727
|
this.table = options.table ?? "kici";
|
|
@@ -31754,7 +32816,7 @@ var FirecrackerScalerBackend = class FirecrackerScalerBackend {
|
|
|
31754
32816
|
});
|
|
31755
32817
|
let alloc;
|
|
31756
32818
|
const onSpawnAbort = () => {
|
|
31757
|
-
this.
|
|
32819
|
+
this.abandonSpawnDetached(agentId, alloc, "spawn aborted (deadline or shutdown)");
|
|
31758
32820
|
};
|
|
31759
32821
|
signal?.addEventListener("abort", onSpawnAbort, { once: true });
|
|
31760
32822
|
try {
|
|
@@ -31893,8 +32955,8 @@ var FirecrackerScalerBackend = class FirecrackerScalerBackend {
|
|
|
31893
32955
|
"kici-agent-id": agentId,
|
|
31894
32956
|
"kici-labels": fullLabels.join(","),
|
|
31895
32957
|
"kici-scaler-managed": "1",
|
|
31896
|
-
"kici-gateway-ip": this.gateway,
|
|
31897
32958
|
...agentToken ? { "kici-agent-token": agentToken } : {},
|
|
32959
|
+
...this.guestExtraHosts ? { "kici-extra-hosts": this.guestExtraHosts } : {},
|
|
31898
32960
|
...matchedLabelSet.backpressureMode ? { "kici-backpressure-mode": matchedLabelSet.backpressureMode } : {},
|
|
31899
32961
|
...Object.keys(acceptedEnv).length > 0 ? { "kici-env": acceptedEnv } : {}
|
|
31900
32962
|
} } });
|
|
@@ -31930,6 +32992,13 @@ var FirecrackerScalerBackend = class FirecrackerScalerBackend {
|
|
|
31930
32992
|
logger$2.warn(`Cleanup after abandoned spawn ${agentId} failed: ${toErrorMessage(err)}`);
|
|
31931
32993
|
}
|
|
31932
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
|
+
}
|
|
31933
33002
|
getScalerContext(agentId) {
|
|
31934
33003
|
const managed = this.agents.get(agentId);
|
|
31935
33004
|
if (!managed) return void 0;
|
|
@@ -32307,7 +33376,7 @@ var FirecrackerScalerBackend = class FirecrackerScalerBackend {
|
|
|
32307
33376
|
}
|
|
32308
33377
|
for (const name of ifaceNames) {
|
|
32309
33378
|
if (!VM_TAP_PATTERN.test(name)) continue;
|
|
32310
|
-
if (
|
|
33379
|
+
if (name === this.bridgeName) continue;
|
|
32311
33380
|
if (allocatedTapsBefore.has(name)) continue;
|
|
32312
33381
|
if (allocatedTapsAfter.has(name)) continue;
|
|
32313
33382
|
if (trackedTapDevices.has(name)) continue;
|
|
@@ -32366,9 +33435,9 @@ var FirecrackerScalerBackend = class FirecrackerScalerBackend {
|
|
|
32366
33435
|
/**
|
|
32367
33436
|
* Default interval for periodic orphan sweeps (15 minutes).
|
|
32368
33437
|
*
|
|
32369
|
-
* Long-running orchestrators need in-process orphan sweeps because
|
|
32370
|
-
*
|
|
32371
|
-
*
|
|
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
|
|
32372
33441
|
* TAPs accumulate until the orchestrator restarts — which can be weeks.
|
|
32373
33442
|
*/
|
|
32374
33443
|
static ORPHAN_SWEEP_INTERVAL_MS = 9e5;
|
|
@@ -32765,8 +33834,8 @@ var FirecrackerScalerBackend = class FirecrackerScalerBackend {
|
|
|
32765
33834
|
this.tailAbortControllers.set(agentId, abortController);
|
|
32766
33835
|
const signal = abortController.signal;
|
|
32767
33836
|
const output = process.stdout;
|
|
32768
|
-
this.tailAndForward(serialLogPath, agentId, "firecracker-serial", signal, output);
|
|
32769
|
-
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 });
|
|
32770
33839
|
child.on("exit", (code, sig) => {
|
|
32771
33840
|
setTimeout(() => {
|
|
32772
33841
|
abortController.abort();
|
|
@@ -32780,7 +33849,7 @@ var FirecrackerScalerBackend = class FirecrackerScalerBackend {
|
|
|
32780
33849
|
signal: sig,
|
|
32781
33850
|
failureType
|
|
32782
33851
|
}), agentId, output, void 0, "firecracker-serial");
|
|
32783
|
-
this.
|
|
33852
|
+
this.abandonSpawnDetached(agentId, alloc, `jailer exited during boot (code ${code}${sig ? `, signal ${sig}` : ""}, ${failureType})`);
|
|
32784
33853
|
}
|
|
32785
33854
|
}, 500);
|
|
32786
33855
|
});
|
|
@@ -33694,7 +34763,7 @@ function registerScalerCommands(program) {
|
|
|
33694
34763
|
process.exit(1);
|
|
33695
34764
|
}
|
|
33696
34765
|
const scalerConfig = await loadScalerConfig(scalerConfigPath, scalerConfigDir);
|
|
33697
|
-
const port = local.server?.port ?? Number(process.env.KICI_PORT ??
|
|
34766
|
+
const port = local.server?.port ?? Number(process.env.KICI_PORT ?? ORCHESTRATOR_DEFAULT_PORT);
|
|
33698
34767
|
const basePath = local.server?.basePath ?? process.env.KICI_BASE_PATH ?? "/";
|
|
33699
34768
|
if ((opts.force ? false : await isOrchestratorHealthy(port, basePath)) && !opts.force) {
|
|
33700
34769
|
const msg = "orchestrator is up and reaps orphans itself; pass --force to reap anyway";
|
|
@@ -33995,7 +35064,7 @@ Token vocabulary:
|
|
|
33995
35064
|
* where no bundler substituted the constant — the same shape `server.ts` and
|
|
33996
35065
|
* `standalone.ts` use for `ORCHESTRATOR_VERSION`.
|
|
33997
35066
|
*/
|
|
33998
|
-
const CLI_VERSION = "0.
|
|
35067
|
+
const CLI_VERSION = "0.12.0";
|
|
33999
35068
|
/**
|
|
34000
35069
|
* Build the kici-admin Commander program with every command group registered.
|
|
34001
35070
|
* Exported so the surface registry can walk the real command tree without
|