@huynle/hub 0.7.1 → 0.7.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +87 -0
- package/Dockerfile +3 -0
- package/README.md +148 -11
- package/package.json +3 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/main.ts +4 -4
- package/packages/claude-code/src/wrapper.ts +3 -2
- package/packages/cli/dist/hub-cli.js +19334 -7849
- package/packages/cli/package.json +11 -0
- package/packages/cli/src/acting.ts +70 -0
- package/packages/cli/src/args.ts +91 -0
- package/packages/cli/src/bootstrap/adapters/archive-stream.ts +276 -0
- package/packages/cli/src/bootstrap/adapters/claude/README.md +63 -0
- package/packages/cli/src/bootstrap/adapters/claude/adapter.ts +250 -0
- package/packages/cli/src/bootstrap/adapters/claude/index.ts +42 -0
- package/packages/cli/src/bootstrap/adapters/claude/node.ts +99 -0
- package/packages/cli/src/bootstrap/adapters/claude/openpgp.ts +221 -0
- package/packages/cli/src/bootstrap/adapters/claude/ports.ts +44 -0
- package/packages/cli/src/bootstrap/adapters/claude/source.ts +160 -0
- package/packages/cli/src/bootstrap/adapters/claude/verify.ts +165 -0
- package/packages/cli/src/bootstrap/adapters/codex/archive.ts +103 -0
- package/packages/cli/src/bootstrap/adapters/codex/index.ts +60 -0
- package/packages/cli/src/bootstrap/adapters/codex/plan.ts +129 -0
- package/packages/cli/src/bootstrap/adapters/codex/sigstore.ts +298 -0
- package/packages/cli/src/bootstrap/adapters/codex/source.ts +102 -0
- package/packages/cli/src/bootstrap/adapters/codex/verify.ts +298 -0
- package/packages/cli/src/bootstrap/adapters/inflate.ts +433 -0
- package/packages/cli/src/bootstrap/adapters/node-services/control.ts +392 -0
- package/packages/cli/src/bootstrap/adapters/node-services/index.ts +23 -0
- package/packages/cli/src/bootstrap/adapters/node-services/launch.ts +223 -0
- package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +222 -0
- package/packages/cli/src/bootstrap/adapters/node-services/render.ts +183 -0
- package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +197 -0
- package/packages/cli/src/bootstrap/adapters/opencode/README.md +204 -0
- package/packages/cli/src/bootstrap/adapters/opencode/apply.ts +205 -0
- package/packages/cli/src/bootstrap/adapters/opencode/archive.ts +91 -0
- package/packages/cli/src/bootstrap/adapters/opencode/config.ts +212 -0
- package/packages/cli/src/bootstrap/adapters/opencode/index.ts +81 -0
- package/packages/cli/src/bootstrap/adapters/opencode/plan.ts +266 -0
- package/packages/cli/src/bootstrap/adapters/opencode/verify.ts +208 -0
- package/packages/cli/src/bootstrap/adapters/readiness.ts +19 -0
- package/packages/cli/src/bootstrap/ansible/index.ts +2 -0
- package/packages/cli/src/bootstrap/ansible/render.ts +279 -0
- package/packages/cli/src/bootstrap/contracts/actions.ts +470 -0
- package/packages/cli/src/bootstrap/contracts/artifact.ts +323 -0
- package/packages/cli/src/bootstrap/contracts/canonical.ts +54 -0
- package/packages/cli/src/bootstrap/contracts/credentials.ts +108 -0
- package/packages/cli/src/bootstrap/contracts/discovery.ts +215 -0
- package/packages/cli/src/bootstrap/contracts/envelope.ts +143 -0
- package/packages/cli/src/bootstrap/contracts/exec.ts +74 -0
- package/packages/cli/src/bootstrap/contracts/harness.ts +202 -0
- package/packages/cli/src/bootstrap/contracts/index.ts +28 -0
- package/packages/cli/src/bootstrap/contracts/journal.ts +198 -0
- package/packages/cli/src/bootstrap/contracts/paths.ts +91 -0
- package/packages/cli/src/bootstrap/contracts/plan.ts +155 -0
- package/packages/cli/src/bootstrap/contracts/platform.ts +62 -0
- package/packages/cli/src/bootstrap/contracts/preview.ts +190 -0
- package/packages/cli/src/bootstrap/contracts/profile.ts +105 -0
- package/packages/cli/src/bootstrap/contracts/secrets.ts +52 -0
- package/packages/cli/src/bootstrap/contracts/validate.ts +212 -0
- package/packages/cli/src/bootstrap/discovery/discover.ts +146 -0
- package/packages/cli/src/bootstrap/discovery/executables.ts +134 -0
- package/packages/cli/src/bootstrap/discovery/host.ts +96 -0
- package/packages/cli/src/bootstrap/discovery/index.ts +6 -0
- package/packages/cli/src/bootstrap/discovery/node-probe.ts +97 -0
- package/packages/cli/src/bootstrap/discovery/probe.ts +41 -0
- package/packages/cli/src/bootstrap/discovery/util.ts +13 -0
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +226 -0
- package/packages/cli/src/bootstrap/enrollment/index.ts +7 -0
- package/packages/cli/src/bootstrap/enrollment/policy.ts +54 -0
- package/packages/cli/src/bootstrap/enrollment/result.ts +63 -0
- package/packages/cli/src/bootstrap/enrollment/status.ts +119 -0
- package/packages/cli/src/bootstrap/enrollment/store.ts +173 -0
- package/packages/cli/src/bootstrap/preview/index.ts +5 -0
- package/packages/cli/src/bootstrap/preview/links.ts +31 -0
- package/packages/cli/src/bootstrap/preview/plan.ts +242 -0
- package/packages/cli/src/bootstrap/preview/preview.ts +104 -0
- package/packages/cli/src/bootstrap/preview/render.ts +30 -0
- package/packages/cli/src/bootstrap/profiles/audit.ts +87 -0
- package/packages/cli/src/bootstrap/profiles/catalog.ts +49 -0
- package/packages/cli/src/bootstrap/profiles/desired.ts +96 -0
- package/packages/cli/src/bootstrap/profiles/drift.ts +286 -0
- package/packages/cli/src/bootstrap/profiles/history.ts +263 -0
- package/packages/cli/src/bootstrap/profiles/index.ts +11 -0
- package/packages/cli/src/bootstrap/profiles/upgrade.ts +276 -0
- package/packages/cli/src/bootstrap/profiles/versions.ts +50 -0
- package/packages/cli/src/bootstrap/release/README.md +115 -0
- package/packages/cli/src/bootstrap/release/assets.ts +39 -0
- package/packages/cli/src/bootstrap/release/cli.ts +332 -0
- package/packages/cli/src/bootstrap/release/harness-pins.json +94 -0
- package/packages/cli/src/bootstrap/release/manifest.ts +118 -0
- package/packages/cli/src/bootstrap/release/pins.ts +192 -0
- package/packages/cli/src/bootstrap/release/text-assets.d.ts +9 -0
- package/packages/cli/src/bootstrap/release/trust-root.json +13 -0
- package/packages/cli/src/bootstrap/release/trust-root.ts +39 -0
- package/packages/cli/src/bootstrap/release/verify.ts +88 -0
- package/packages/cli/src/bootstrap/transaction/builtin.ts +312 -0
- package/packages/cli/src/bootstrap/transaction/engine.ts +994 -0
- package/packages/cli/src/bootstrap/transaction/executor.ts +112 -0
- package/packages/cli/src/bootstrap/transaction/fs.ts +194 -0
- package/packages/cli/src/bootstrap/transaction/index.ts +12 -0
- package/packages/cli/src/bootstrap/transaction/json-merge.ts +403 -0
- package/packages/cli/src/bootstrap/transaction/lock.ts +132 -0
- package/packages/cli/src/bootstrap/transaction/resolve.ts +48 -0
- package/packages/cli/src/bootstrap/transaction/result.ts +60 -0
- package/packages/cli/src/bootstrap/transaction/state.ts +88 -0
- package/packages/cli/src/bootstrap/transaction/store.ts +189 -0
- package/packages/cli/src/bootstrap/transaction/stream.ts +254 -0
- package/packages/cli/src/cli.ts +814 -0
- package/packages/cli/src/credentials.ts +75 -0
- package/packages/cli/src/decisions.ts +106 -0
- package/packages/cli/src/format.ts +185 -0
- package/packages/cli/src/grants.ts +105 -0
- package/packages/cli/src/http.ts +86 -0
- package/packages/cli/src/launches.ts +157 -0
- package/packages/cli/src/login.ts +160 -0
- package/packages/cli/src/main.ts +63 -0
- package/packages/cli/src/mcp.ts +879 -0
- package/packages/cli/src/messaging.ts +108 -0
- package/packages/cli/src/node.ts +279 -0
- package/packages/cli/src/openshell.ts +166 -0
- package/packages/cli/src/setup/commands.ts +809 -0
- package/packages/cli/src/setup/contributors.ts +171 -0
- package/packages/cli/src/setup/dispatch.ts +74 -0
- package/packages/cli/src/setup/launch-driver.ts +109 -0
- package/packages/cli/src/setup/runtime.ts +456 -0
- package/packages/cli/src/setup/spoke-probe.ts +120 -0
- package/packages/cli/src/setup/state.ts +244 -0
- package/packages/cli/src/setup/user-bin.ts +39 -0
- package/packages/cli/src/spoke-services.ts +109 -0
- package/packages/cli/src/watch.ts +28 -0
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/main.ts +3 -2
- package/packages/codex/src/spoke.ts +4 -1
- package/packages/codex/src/wrapper.ts +7 -3
- package/packages/node/package.json +1 -1
- package/packages/node/src/harnesses.ts +39 -4
- package/packages/node/src/run.ts +1 -1
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/harness-events.ts +2 -2
- package/packages/protocol/src/index.ts +70 -7
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/client.ts +55 -4
- package/packages/sdk/src/errors.ts +1 -0
- package/packages/sdk/src/sse.ts +4 -1
- package/packages/sdk/src/types.ts +59 -0
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/enroll.ts +26 -4
- package/src/central/agent/app-tokens.ts +27 -17
- package/src/central/agent/archive-inactive.ts +66 -0
- package/src/central/agent/directory-routes.ts +155 -12
- package/src/central/agent/directory.ts +1 -1
- package/src/central/agent/errors.ts +2 -0
- package/src/central/agent/event-cursor.ts +49 -0
- package/src/central/agent/event-writers.ts +181 -0
- package/src/central/agent/events-routes.ts +148 -0
- package/src/central/agent/events.ts +277 -0
- package/src/central/agent/names.ts +1 -1
- package/src/central/agent/read-routes.ts +32 -36
- package/src/central/agent/routes.ts +14 -11
- package/src/central/agent/watch.ts +65 -174
- package/src/central/agent/webhook-routes.ts +10 -2
- package/src/central/agent/webhooks.ts +49 -5
- package/src/central/auth.ts +127 -1
- package/src/central/backend/index.ts +3 -0
- package/src/central/backend/types.ts +2 -0
- package/src/central/http.ts +116 -32
- package/src/central/mcp-http.ts +116 -0
- package/src/central/push.ts +14 -1
- package/src/central/registry.ts +34 -5
- package/src/central/store.ts +60 -0
- package/src/config.ts +70 -7
- package/src/index.ts +90 -91
- package/src/spoke/launchers.ts +109 -3
- package/src/spoke/spoke.ts +8 -4
- package/src/spoke/timeline.ts +59 -3
- package/src/spoke/tracker.ts +59 -6
- package/src/status.ts +97 -6
- package/web/dist/apple-touch-icon.png +0 -0
- package/web/dist/assets/index-Dm8OmkaO.css +1 -0
- package/web/dist/assets/index-FUJbj_Du.js +20 -0
- package/web/dist/favicon.ico +0 -0
- package/web/dist/icon-192.png +0 -0
- package/web/dist/icon-512.png +0 -0
- package/web/dist/icon-maskable-512.png +0 -0
- package/web/dist/icon.svg +5 -5
- package/web/dist/index.html +10 -4
- package/web/dist/manifest.webmanifest +2 -2
- package/web/dist/sw.js +4 -1
- package/web/dist/assets/index-D9zMbxSg.css +0 -1
- package/web/dist/assets/index-db1z4duA.js +0 -20
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// OpenCode readiness (contract §3.10, T14): real probes, never presence.
|
|
2
|
+
//
|
|
3
|
+
// version-exec the binary itself (hub's `current` version, else what the PATH entry resolves to; never a
|
|
4
|
+
// container shim, which would test another environment) runs `opencode --version` and reports
|
|
5
|
+
// OpenCode 2 (and the pinned version, when hub installed it).
|
|
6
|
+
// provider-auth `opencode models` answers with at least one provider/model: the user's providers load and
|
|
7
|
+
// list models with the user's own, user-local configuration and credentials. It sends no prompt
|
|
8
|
+
// (it costs nothing). A stronger probe (a real completion) can be injected.
|
|
9
|
+
// spoke-connected the hub sees this machine's OpenCode spoke (an injected client: the integrator asks the hub with
|
|
10
|
+
// the machine credential; without one this probe fails, so the harness is never ready by default).
|
|
11
|
+
//
|
|
12
|
+
// Every process is a fixed argv of the harness binary (OPENCODE_PROBE_ARGV) run through an injected ProcessRunner:
|
|
13
|
+
// no shell, no command from a plan, an absolute file, bounded time and output. Probe details are built here (a
|
|
14
|
+
// version, counts, an exit status), never the harness's raw output, and anything credential-shaped is withheld.
|
|
15
|
+
import { execFile } from "node:child_process"
|
|
16
|
+
import { compareVersions, executionContextOf, isSecretValue, REQUIRED_PROBES, rootPathIn, SEMVER, type AdapterContext, type HarnessVerification, type Probe } from "../../contracts/index.ts"
|
|
17
|
+
import { discover } from "../../discovery/discover.ts"
|
|
18
|
+
import type { DiscoveryProbe } from "../../discovery/probe.ts"
|
|
19
|
+
import { txFail, txOk, type TxResult } from "../../transaction/result.ts"
|
|
20
|
+
import { OPENCODE_BINARY_REL } from "./archive.ts"
|
|
21
|
+
import { MIN_OPENCODE_VERSION } from "./plan.ts"
|
|
22
|
+
|
|
23
|
+
/** The only argv the adapter ever runs (after the absolute binary path). */
|
|
24
|
+
export const OPENCODE_PROBE_ARGV = Object.freeze({ version: Object.freeze(["--version"]), models: Object.freeze(["models"]) }) as {
|
|
25
|
+
readonly version: readonly string[]
|
|
26
|
+
readonly models: readonly string[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ProcessRequest {
|
|
30
|
+
/** Absolute path of the harness binary. */
|
|
31
|
+
readonly file: string
|
|
32
|
+
readonly argv: readonly string[]
|
|
33
|
+
/** The user's own environment (provider configuration stays user-local; nothing here is recorded). */
|
|
34
|
+
readonly env: Readonly<Record<string, string | undefined>>
|
|
35
|
+
readonly timeoutMs: number
|
|
36
|
+
readonly maxOutputBytes: number
|
|
37
|
+
readonly signal?: AbortSignal
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ProcessOutcome {
|
|
41
|
+
/** null when it was killed (timeout, signal). */
|
|
42
|
+
readonly exitCode: number | null
|
|
43
|
+
readonly stdout: string
|
|
44
|
+
/** Read only to recognise the cold-start activation failure (ACTIVATION_FAILURE); never reported raw. */
|
|
45
|
+
readonly stderr?: string
|
|
46
|
+
readonly timedOut: boolean
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Runs one fixed argv without a shell. Tests inject fakes; the CLI injects nodeProcessRunner. */
|
|
50
|
+
export interface ProcessRunner {
|
|
51
|
+
run(request: ProcessRequest): Promise<ProcessOutcome>
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** child_process.execFile with shell: false, bounded output and time; stdin closed. */
|
|
55
|
+
export const nodeProcessRunner: ProcessRunner = {
|
|
56
|
+
run: (request) =>
|
|
57
|
+
new Promise((resolve, reject) => {
|
|
58
|
+
if (!request.file.startsWith("/")) return reject(new Error("the harness binary must be an absolute path"))
|
|
59
|
+
const env = Object.fromEntries(Object.entries(request.env).filter((e): e is [string, string] => typeof e[1] === "string"))
|
|
60
|
+
const child = execFile(
|
|
61
|
+
request.file,
|
|
62
|
+
[...request.argv],
|
|
63
|
+
{ shell: false, env, timeout: request.timeoutMs, maxBuffer: request.maxOutputBytes, encoding: "utf8", windowsHide: true, ...(request.signal ? { signal: request.signal } : {}) },
|
|
64
|
+
(error, stdout, stderr) => {
|
|
65
|
+
const e = error as (NodeJS.ErrnoException & { killed?: boolean; code?: number | string }) | null
|
|
66
|
+
if (e && typeof e.code === "string" && e.code !== "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") return reject(e)
|
|
67
|
+
resolve({ exitCode: e ? (typeof e.code === "number" ? e.code : null) : 0, stdout: String(stdout ?? ""), stderr: String(stderr ?? ""), timedOut: Boolean(e?.killed) })
|
|
68
|
+
},
|
|
69
|
+
)
|
|
70
|
+
child.stdin?.end()
|
|
71
|
+
}),
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A provider probe beyond the default model listing (e.g. a real, tiny completion), injected by the integrator. */
|
|
75
|
+
export type ProviderProbe = (input: { readonly binary: string; readonly context: AdapterContext; readonly runner: ProcessRunner }) => Promise<{ readonly ok: boolean; readonly detail?: string }>
|
|
76
|
+
|
|
77
|
+
/** Whether the hub sees this machine's OpenCode spoke; the integrator asks the hub with the machine credential. */
|
|
78
|
+
export interface SpokeProbe {
|
|
79
|
+
connected(input: { readonly harness: "opencode"; readonly context: AdapterContext }): Promise<{ readonly ok: boolean; readonly detail?: string }>
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface OpencodeVerifyOptions {
|
|
83
|
+
readonly runner: ProcessRunner
|
|
84
|
+
/** The binary to probe (locateOpencodeBinary); undefined: not found, so every probe fails. */
|
|
85
|
+
readonly binary: string | undefined
|
|
86
|
+
readonly spoke?: SpokeProbe
|
|
87
|
+
readonly provider?: ProviderProbe
|
|
88
|
+
/** The version hub installed and made current; the binary must report exactly it. */
|
|
89
|
+
readonly expectVersion?: string
|
|
90
|
+
readonly versionTimeoutMs?: number
|
|
91
|
+
readonly providerTimeoutMs?: number
|
|
92
|
+
/** The pause before the one retry after a cold-start activation failure (default 1 s). */
|
|
93
|
+
readonly activationRetryDelayMs?: number
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* OpenCode 2's CLI gives a location's plugins ~5 s to activate in its background service, then fails with HTTP 503
|
|
98
|
+
* "Location plugins did not finish activating". On the first use of a directory (plugins loaded cold, MCP servers
|
|
99
|
+
* connecting) that can happen once; the service keeps activating, so the probe tries again once.
|
|
100
|
+
*/
|
|
101
|
+
export const ACTIVATION_FAILURE = /did not finish activating/i
|
|
102
|
+
const ACTIVATION_RETRY_DELAY_MS = 1000
|
|
103
|
+
|
|
104
|
+
const VERSION_TIMEOUT_MS = 15_000
|
|
105
|
+
const PROVIDER_TIMEOUT_MS = 90_000
|
|
106
|
+
const MAX_OUTPUT = 1024 * 1024
|
|
107
|
+
const VERSION_IN = /(?:^|[^0-9.])v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)(?![0-9A-Za-z.-])/
|
|
108
|
+
const MODEL_LINE = /^[A-Za-z0-9][A-Za-z0-9._-]*\/\S+$/
|
|
109
|
+
|
|
110
|
+
/** One line for people; anything credential-shaped is withheld. */
|
|
111
|
+
const safe = (detail: string): string => (isSecretValue(detail) ? "(details withheld: they looked like they carried a credential)" : detail.replace(/\s+/g, " ").trim().slice(0, 200))
|
|
112
|
+
const errorText = (error: unknown) => (error instanceof Error ? error.message : String(error))
|
|
113
|
+
|
|
114
|
+
async function runOnce(options: OpencodeVerifyOptions, context: AdapterContext, argv: readonly string[], timeoutMs: number): Promise<ProcessOutcome | string> {
|
|
115
|
+
try {
|
|
116
|
+
return await options.runner.run({ file: options.binary!, argv, env: context.env, timeoutMs, maxOutputBytes: MAX_OUTPUT, ...(context.signal ? { signal: context.signal } : {}) })
|
|
117
|
+
} catch (error) {
|
|
118
|
+
return `could not run ${options.binary}: ${errorText(error)}`
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const coldStart = (r: ProcessOutcome | string): boolean => typeof r !== "string" && r.exitCode !== 0 && ACTIVATION_FAILURE.test(`${r.stderr ?? ""}\n${r.stdout}`)
|
|
123
|
+
|
|
124
|
+
/** Runs one probe argv; a cold-start activation failure is retried once (ACTIVATION_FAILURE). */
|
|
125
|
+
async function run(options: OpencodeVerifyOptions, context: AdapterContext, argv: readonly string[], timeoutMs: number): Promise<ProcessOutcome | string> {
|
|
126
|
+
const first = await runOnce(options, context, argv, timeoutMs)
|
|
127
|
+
if (!coldStart(first) || context.signal?.aborted) return first
|
|
128
|
+
const delay = options.activationRetryDelayMs ?? ACTIVATION_RETRY_DELAY_MS
|
|
129
|
+
if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay))
|
|
130
|
+
return runOnce(options, context, argv, timeoutMs)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const failedRun = (r: ProcessOutcome) =>
|
|
134
|
+
r.timedOut ? "timed out" : r.exitCode === null ? "was killed" : `exited ${r.exitCode}${coldStart(r) ? " (OpenCode's plugins did not finish activating in time, twice: start OpenCode once, then verify again)" : ""}`
|
|
135
|
+
|
|
136
|
+
async function versionProbe(options: OpencodeVerifyOptions, context: AdapterContext): Promise<Omit<Probe, "observedAt">> {
|
|
137
|
+
const r = await run(options, context, OPENCODE_PROBE_ARGV.version, options.versionTimeoutMs ?? VERSION_TIMEOUT_MS)
|
|
138
|
+
if (typeof r === "string") return { kind: "version-exec", ok: false, detail: r }
|
|
139
|
+
if (r.exitCode !== 0) return { kind: "version-exec", ok: false, detail: `opencode --version ${failedRun(r)}` }
|
|
140
|
+
const version = VERSION_IN.exec(r.stdout)?.[1]
|
|
141
|
+
if (!version || !SEMVER.test(version)) return { kind: "version-exec", ok: false, detail: "opencode --version printed no version" }
|
|
142
|
+
if (compareVersions(version, MIN_OPENCODE_VERSION) < 0) return { kind: "version-exec", ok: false, detail: `opencode ${version} is older than ${MIN_OPENCODE_VERSION} (the Hub plugin needs OpenCode 2)` }
|
|
143
|
+
if (options.expectVersion && version !== options.expectVersion) return { kind: "version-exec", ok: false, detail: `opencode ${version} runs, but hub made ${options.expectVersion} current` }
|
|
144
|
+
return { kind: "version-exec", ok: true, detail: `opencode ${version}` }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function providerProbe(options: OpencodeVerifyOptions, context: AdapterContext): Promise<Omit<Probe, "observedAt">> {
|
|
148
|
+
if (options.provider) {
|
|
149
|
+
try {
|
|
150
|
+
const r = await options.provider({ binary: options.binary!, context, runner: options.runner })
|
|
151
|
+
return { kind: "provider-auth", ok: r.ok, ...(r.detail ? { detail: r.detail } : {}) }
|
|
152
|
+
} catch (error) {
|
|
153
|
+
return { kind: "provider-auth", ok: false, detail: `the provider probe failed: ${errorText(error)}` }
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const r = await run(options, context, OPENCODE_PROBE_ARGV.models, options.providerTimeoutMs ?? PROVIDER_TIMEOUT_MS)
|
|
157
|
+
if (typeof r === "string") return { kind: "provider-auth", ok: false, detail: r }
|
|
158
|
+
if (r.exitCode !== 0) return { kind: "provider-auth", ok: false, detail: `opencode models ${failedRun(r)}` }
|
|
159
|
+
const models = r.stdout.split("\n").map((l) => l.trim()).filter((l) => MODEL_LINE.test(l))
|
|
160
|
+
if (models.length === 0) return { kind: "provider-auth", ok: false, detail: "opencode lists no models: sign in to a provider (opencode auth login) or configure one" }
|
|
161
|
+
const providers = new Set(models.map((m) => m.slice(0, m.indexOf("/"))))
|
|
162
|
+
return { kind: "provider-auth", ok: true, detail: `${models.length} models from ${providers.size} providers` }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function spokeProbe(options: OpencodeVerifyOptions, context: AdapterContext): Promise<Omit<Probe, "observedAt">> {
|
|
166
|
+
if (!options.spoke) return { kind: "spoke-connected", ok: false, detail: "no hub spoke probe is configured" }
|
|
167
|
+
try {
|
|
168
|
+
const r = await options.spoke.connected({ harness: "opencode", context })
|
|
169
|
+
return { kind: "spoke-connected", ok: r.ok, ...(r.detail ? { detail: r.detail } : {}) }
|
|
170
|
+
} catch (error) {
|
|
171
|
+
return { kind: "spoke-connected", ok: false, detail: `the hub could not be asked: ${errorText(error)}` }
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Runs the required probes and reports each, passing or not (HarnessAdapter.verify). */
|
|
176
|
+
export async function verifyOpencode(context: AdapterContext, options: OpencodeVerifyOptions): Promise<HarnessVerification> {
|
|
177
|
+
let results: Omit<Probe, "observedAt">[]
|
|
178
|
+
if (!options.binary) {
|
|
179
|
+
results = REQUIRED_PROBES.opencode.map((kind) => ({ kind, ok: false, detail: "OpenCode was not found on this host" }))
|
|
180
|
+
} else {
|
|
181
|
+
// Each probe on its own, so the report names every problem at once.
|
|
182
|
+
results = [await versionProbe(options, context), await providerProbe(options, context), await spokeProbe(options, context)]
|
|
183
|
+
}
|
|
184
|
+
const observedAt = context.now()
|
|
185
|
+
return { harness: "opencode", probes: results.map((p) => ({ ...p, observedAt, ...(p.detail !== undefined ? { detail: safe(p.detail) } : {}) })) }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* The binary to probe on this host: hub's `current` OpenCode (through the link, to its version directory), else the
|
|
190
|
+
* one on PATH, followed to what it resolves to. A container shim is refused: running it would test the container,
|
|
191
|
+
* not this host (contract §3.10: version-exec runs what the shim resolves to, and that is not here).
|
|
192
|
+
*/
|
|
193
|
+
export function locateOpencodeBinary(probe: DiscoveryProbe): TxResult<string> {
|
|
194
|
+
const snap = discover(probe, { now: 0 })
|
|
195
|
+
if (!snap.ok) return txFail("invalid", "this host could not be discovered")
|
|
196
|
+
const data = snap.value.roots.find((r) => r.id === "data")
|
|
197
|
+
const base = data ? rootPathIn(data, executionContextOf(snap.value)) : undefined
|
|
198
|
+
if (base) {
|
|
199
|
+
const current = probe.realpath(`${base}/hub/harnesses/opencode/current/${OPENCODE_BINARY_REL}`)
|
|
200
|
+
if (current && probe.executable(current)) return txOk(current)
|
|
201
|
+
}
|
|
202
|
+
const presence = snap.value.harnesses.find((h) => h.id === "opencode")
|
|
203
|
+
if (!presence?.installed || !presence.path) return txFail("not_found", "OpenCode is not installed on this host")
|
|
204
|
+
const shim = snap.value.topology.shims.find((s) => s.name === "opencode" && s.path === presence.path)
|
|
205
|
+
if (shim?.kind === "container-shim") return txFail("not_found", `${presence.path} runs OpenCode in a container${shim.resolvesTo ? ` (${shim.resolvesTo})` : ""}; verify it there`)
|
|
206
|
+
const target = shim?.resolvesTo ?? presence.path
|
|
207
|
+
return probe.executable(target) ? txOk(target) : txFail("not_found", `${target} is not an executable on this host`)
|
|
208
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// The readiness gate every verify-harness executor shares (contract v1.2, docs/bootstrap-contracts.md §10). The
|
|
2
|
+
// executor classifies its verification with harnessReadiness and reports it through onReadiness; then:
|
|
3
|
+
//
|
|
4
|
+
// ready the step passes
|
|
5
|
+
// failed an integrity probe failed (the install is broken): harness_not_ready, so the apply rolls back
|
|
6
|
+
// pending only user probes wait (provider login, spoke): harness_not_ready with requireReady (the default,
|
|
7
|
+
// and `hubctl setup apply --require-ready`); without it the step passes and the harness is reported
|
|
8
|
+
// "installed, not ready". It is never advertised as ready: only `ready` offers a capability.
|
|
9
|
+
import type { HarnessReadiness } from "../contracts/index.ts"
|
|
10
|
+
|
|
11
|
+
export interface ReadinessGate {
|
|
12
|
+
/** Fail unless ready (default true). false: a harness only waiting on the user passes as installed, not ready. */
|
|
13
|
+
readonly requireReady?: boolean
|
|
14
|
+
/** Sees the classified readiness of every verification (for the apply report); cannot change it. */
|
|
15
|
+
readonly onReadiness?: (readiness: HarnessReadiness) => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Whether a verify-harness step that is not ready may still pass: only when it waits on the user alone. */
|
|
19
|
+
export const passesAsPending = (readiness: HarnessReadiness, gate: ReadinessGate): boolean => readiness.state === "pending" && gate.requireReady === false
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// Renders a bootstrap profile, or a previewed setup plan, as the vars of the optional Ansible role
|
|
2
|
+
// (contrib/ansible/roles/hub_bootstrap). The role reimplements nothing: it stages the signed hubctl pinned here and
|
|
3
|
+
// runs the same `hubctl setup preview / apply / verify` a person would run directly, with the same canonical profile
|
|
4
|
+
// and the same consent flags. So the vars carry only:
|
|
5
|
+
// - the profile, canonical, and its digest (the direct path's input, byte for byte);
|
|
6
|
+
// - when rendered from a plan, that plan's digest, host and snapshot, so the role refuses to apply anything but
|
|
7
|
+
// the plan that was previewed;
|
|
8
|
+
// - the hubctl artifacts and harness pins of a *verified* manifest (VerifiedManifest: only verifyManifest makes
|
|
9
|
+
// one), so the role can check the downloads before hubctl checks the signature again on the host;
|
|
10
|
+
// - explicit consent flags, all off by default.
|
|
11
|
+
// Never a credential: inputs are validated, and the rendered vars are scanned (findSecrets) and refused, not
|
|
12
|
+
// redacted, if anything credential-shaped got through. Enrollment is never part of the vars or the role: a plan that
|
|
13
|
+
// enrolls is refused, because the device-code flow is a separate step a person approves.
|
|
14
|
+
//
|
|
15
|
+
// v1.1: the rendered profile always says `enrollment: "separate"`, so hubctl plans without enroll-machine on the
|
|
16
|
+
// host and the role's plan digest equals the direct path's (`--enrollment separate`, or the same profile). A plan
|
|
17
|
+
// rendered together with its snapshot is checked against the contract's one canonical snapshotDigest.
|
|
18
|
+
import {
|
|
19
|
+
canonicalJSON,
|
|
20
|
+
checkHarnessPin,
|
|
21
|
+
digestOf,
|
|
22
|
+
findSecrets,
|
|
23
|
+
migrateProfile,
|
|
24
|
+
planDigest,
|
|
25
|
+
selectArtifact,
|
|
26
|
+
snapshotDigest,
|
|
27
|
+
validateDiscoverySnapshot,
|
|
28
|
+
validateSetupPlan,
|
|
29
|
+
type ErrorCode,
|
|
30
|
+
type Issue,
|
|
31
|
+
type Profile,
|
|
32
|
+
type SetupPlan,
|
|
33
|
+
type VerifiedManifest,
|
|
34
|
+
} from "../contracts/index.ts"
|
|
35
|
+
|
|
36
|
+
export const ANSIBLE_VARS_SCHEMA = "hub.ansible-vars"
|
|
37
|
+
export const ANSIBLE_VARS_VERSION = 1
|
|
38
|
+
/** The manifest file a release publishes next to its hubctl binaries (I5). */
|
|
39
|
+
export const MANIFEST_FILE = "hubctl-manifest.json"
|
|
40
|
+
|
|
41
|
+
/** Each consent var and the hubctl flag it becomes, in argv order. Never a default. */
|
|
42
|
+
export const ROLE_FLAGS = Object.freeze({
|
|
43
|
+
hub_bootstrap_escalate: "--escalate",
|
|
44
|
+
hub_bootstrap_confirm_activation: "--confirm-activation",
|
|
45
|
+
hub_bootstrap_allow_downgrade: "--allow-downgrade",
|
|
46
|
+
} as const)
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* v1.2: apply-only flags (not consent). `--require-ready` makes apply fail and roll back unless every harness is
|
|
50
|
+
* ready; without it apply commits a harness that only waits on the user, and the role's verify step (with
|
|
51
|
+
* hub_bootstrap_require_ready) is the readiness gate.
|
|
52
|
+
*/
|
|
53
|
+
export const ROLE_APPLY_FLAGS = Object.freeze({
|
|
54
|
+
hub_bootstrap_apply_require_ready: "--require-ready",
|
|
55
|
+
} as const)
|
|
56
|
+
|
|
57
|
+
export interface RenderOptions {
|
|
58
|
+
/** https base URL of the hub release holding hubctl-manifest.json and the hubctl-<target> files. */
|
|
59
|
+
readonly releaseBaseURL: string
|
|
60
|
+
readonly escalate?: boolean
|
|
61
|
+
readonly confirmActivation?: boolean
|
|
62
|
+
readonly allowDowngrade?: boolean
|
|
63
|
+
/** Fail the play when `hubctl setup verify` finds a harness not ready (default true). */
|
|
64
|
+
readonly requireReady?: boolean
|
|
65
|
+
/** v1.2: pass --require-ready to `hubctl setup apply` (default false: apply commits installed-not-ready harnesses). */
|
|
66
|
+
readonly applyRequireReady?: boolean
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface RenderInput {
|
|
70
|
+
/** A bootstrap profile document (older versions are migrated). */
|
|
71
|
+
readonly profile: unknown
|
|
72
|
+
/** The plan `hubctl setup preview` showed for one host, to pin the role to it. */
|
|
73
|
+
readonly plan?: unknown
|
|
74
|
+
/** v1.1: the discovery snapshot that plan was made from, to check the plan against it (canonical snapshotDigest). */
|
|
75
|
+
readonly snapshot?: unknown
|
|
76
|
+
readonly manifest: VerifiedManifest
|
|
77
|
+
readonly options: RenderOptions
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface PinnedArtifact {
|
|
81
|
+
readonly url: string
|
|
82
|
+
readonly file: string
|
|
83
|
+
readonly size: number
|
|
84
|
+
readonly sha256: string
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface PinnedHarness {
|
|
88
|
+
readonly harness: string
|
|
89
|
+
readonly version: string
|
|
90
|
+
readonly target: string
|
|
91
|
+
readonly sha256: string
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface ExpectedPlan {
|
|
95
|
+
readonly planDigest: string
|
|
96
|
+
readonly hostFingerprint: string
|
|
97
|
+
readonly snapshotDigest: string
|
|
98
|
+
readonly target: string
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface RoleVars {
|
|
102
|
+
readonly hub_bootstrap_vars_schema: typeof ANSIBLE_VARS_SCHEMA
|
|
103
|
+
readonly hub_bootstrap_vars_version: typeof ANSIBLE_VARS_VERSION
|
|
104
|
+
readonly hub_bootstrap_release: string
|
|
105
|
+
readonly hub_bootstrap_channel: string
|
|
106
|
+
readonly hub_bootstrap_minimum_version: string
|
|
107
|
+
readonly hub_bootstrap_manifest_url: string
|
|
108
|
+
/** Per supported target: the hubctl download and its pinned size and digest. */
|
|
109
|
+
readonly hub_bootstrap_artifacts: Readonly<Record<string, PinnedArtifact>>
|
|
110
|
+
readonly hub_bootstrap_harness_pins: readonly PinnedHarness[]
|
|
111
|
+
readonly hub_bootstrap_profile: Profile
|
|
112
|
+
readonly hub_bootstrap_profile_digest: string
|
|
113
|
+
readonly hub_bootstrap_expected_plan: ExpectedPlan | null
|
|
114
|
+
readonly hub_bootstrap_escalate: boolean
|
|
115
|
+
readonly hub_bootstrap_confirm_activation: boolean
|
|
116
|
+
readonly hub_bootstrap_allow_downgrade: boolean
|
|
117
|
+
readonly hub_bootstrap_require_ready: boolean
|
|
118
|
+
readonly hub_bootstrap_apply_require_ready: boolean
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The order keys appear in the rendered YAML. */
|
|
122
|
+
const VAR_ORDER: readonly (keyof RoleVars)[] = [
|
|
123
|
+
"hub_bootstrap_vars_schema",
|
|
124
|
+
"hub_bootstrap_vars_version",
|
|
125
|
+
"hub_bootstrap_release",
|
|
126
|
+
"hub_bootstrap_channel",
|
|
127
|
+
"hub_bootstrap_minimum_version",
|
|
128
|
+
"hub_bootstrap_manifest_url",
|
|
129
|
+
"hub_bootstrap_artifacts",
|
|
130
|
+
"hub_bootstrap_harness_pins",
|
|
131
|
+
"hub_bootstrap_profile",
|
|
132
|
+
"hub_bootstrap_profile_digest",
|
|
133
|
+
"hub_bootstrap_expected_plan",
|
|
134
|
+
"hub_bootstrap_escalate",
|
|
135
|
+
"hub_bootstrap_confirm_activation",
|
|
136
|
+
"hub_bootstrap_allow_downgrade",
|
|
137
|
+
"hub_bootstrap_require_ready",
|
|
138
|
+
"hub_bootstrap_apply_require_ready",
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
export interface RenderError {
|
|
142
|
+
readonly code: ErrorCode
|
|
143
|
+
readonly message: string
|
|
144
|
+
readonly path?: string
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export type RenderResult = { readonly ok: true; readonly vars: RoleVars; readonly yaml: string } | { readonly ok: false; readonly error: RenderError }
|
|
148
|
+
|
|
149
|
+
const refuse = (code: ErrorCode, message: string, path?: string): RenderResult => ({ ok: false, error: { code, message, ...(path === undefined ? {} : { path }) } })
|
|
150
|
+
|
|
151
|
+
const describeIssues = (issues: readonly Issue[]) => issues.map((i) => `${i.path || "/"}: ${i.message}`).join("; ")
|
|
152
|
+
|
|
153
|
+
/** A pin issue code as an envelope error code. */
|
|
154
|
+
const pinCode = (code: string): ErrorCode => (code === "unpinned_artifact" || code === "integrity_mismatch" ? code : "invalid")
|
|
155
|
+
|
|
156
|
+
function releaseBase(raw: string): string | undefined {
|
|
157
|
+
let url: URL
|
|
158
|
+
try {
|
|
159
|
+
url = new URL(raw)
|
|
160
|
+
} catch {
|
|
161
|
+
return undefined
|
|
162
|
+
}
|
|
163
|
+
if (url.protocol !== "https:" || url.username || url.password || url.search || url.hash || raw.includes("?") || raw.includes("#")) return undefined
|
|
164
|
+
return url.href.replace(/\/+$/, "")
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Checks a plan against the profile, the manifest and the consent given; undefined when it may be rendered. */
|
|
168
|
+
function checkPlan(plan: SetupPlan, profile: Profile, manifest: VerifiedManifest, options: RenderOptions): RenderResult | undefined {
|
|
169
|
+
if (plan.profile.id !== profile.id) return refuse("invalid", `the plan is for profile ${plan.profile.id}, not ${profile.id}`, "/plan/profile/id")
|
|
170
|
+
const artifact = selectArtifact(manifest, plan.target)
|
|
171
|
+
if (!artifact.ok) return refuse("wrong_platform", describeIssues(artifact.issues), "/plan/target")
|
|
172
|
+
for (const [i, action] of plan.actions.entries()) {
|
|
173
|
+
const at = `/plan/actions/${i}`
|
|
174
|
+
if (action.kind === "enroll-machine")
|
|
175
|
+
return refuse("usage", "enrollment is a separate, explicit step: run `hubctl machine enroll` on the host, then preview again", at)
|
|
176
|
+
if (action.kind === "install-artifact") {
|
|
177
|
+
if (action.version !== manifest.release || action.target !== plan.target)
|
|
178
|
+
return refuse("unpinned_artifact", `the plan installs hubctl ${action.version} for ${action.target}, but the manifest pins ${manifest.release} for ${plan.target}`, at)
|
|
179
|
+
if (action.sha256 !== artifact.value.sha256) return refuse("integrity_mismatch", "the plan's hubctl digest differs from the manifest's", at)
|
|
180
|
+
}
|
|
181
|
+
if (action.kind === "install-harness") {
|
|
182
|
+
const pin = checkHarnessPin(manifest, action)
|
|
183
|
+
if (!pin.ok) return refuse(pinCode(pin.issues[0]!.code), describeIssues(pin.issues), at)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (plan.requiresEscalation && !options.escalate) return refuse("escalation_required", "the plan has elevated actions; pass escalate explicitly to allow them", "/plan/requiresEscalation")
|
|
187
|
+
if (!plan.requiresEscalation && options.escalate) return refuse("usage", "the plan needs no escalation; do not pass escalate", "/options/escalate")
|
|
188
|
+
if (plan.activatesServices && !options.confirmActivation) return refuse("needs_user", "the plan activates services; pass confirmActivation explicitly to allow it", "/plan/activatesServices")
|
|
189
|
+
return undefined
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function renderRoleVars(input: RenderInput): RenderResult {
|
|
193
|
+
const { manifest, options } = input
|
|
194
|
+
const base = releaseBase(options.releaseBaseURL)
|
|
195
|
+
if (!base) return refuse("usage", "the release URL must be https, without credentials, query or fragment", "/options/releaseBaseURL")
|
|
196
|
+
const migrated = migrateProfile(input.profile)
|
|
197
|
+
if (!migrated.ok) return refuse("invalid", `invalid profile: ${describeIssues(migrated.issues)}`, "/profile")
|
|
198
|
+
// The role never enrolls: hubctl on the host plans with a separate enrollment.
|
|
199
|
+
const profile = JSON.parse(canonicalJSON({ ...migrated.value, enrollment: "separate" })) as Profile
|
|
200
|
+
if (options.escalate && profile.escalation !== "explicit") return refuse("usage", "the profile never escalates; escalate cannot be passed", "/options/escalate")
|
|
201
|
+
|
|
202
|
+
let expected: ExpectedPlan | null = null
|
|
203
|
+
if (input.plan !== undefined) {
|
|
204
|
+
const checked = validateSetupPlan(input.plan)
|
|
205
|
+
if (!checked.ok) return refuse("invalid", `invalid plan: ${describeIssues(checked.issues)}`, "/plan")
|
|
206
|
+
const plan = checked.value
|
|
207
|
+
const problem = checkPlan(plan, profile, manifest, options)
|
|
208
|
+
if (problem) return problem
|
|
209
|
+
if (input.snapshot !== undefined) {
|
|
210
|
+
const snap = validateDiscoverySnapshot(input.snapshot)
|
|
211
|
+
if (!snap.ok) return refuse("invalid", `invalid snapshot: ${describeIssues(snap.issues)}`, "/snapshot")
|
|
212
|
+
if (snapshotDigest(snap.value) !== plan.snapshotDigest) return refuse("preview_stale", "the plan was not made from this snapshot; preview again", "/plan/snapshotDigest")
|
|
213
|
+
}
|
|
214
|
+
expected = { planDigest: planDigest(plan), hostFingerprint: plan.hostFingerprint, snapshotDigest: plan.snapshotDigest, target: plan.target }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const artifacts: Record<string, PinnedArtifact> = {}
|
|
218
|
+
for (const a of manifest.artifacts) artifacts[a.target] = { url: `${base}/${a.file}`, file: a.file, size: a.size, sha256: a.sha256 }
|
|
219
|
+
const vars: RoleVars = {
|
|
220
|
+
hub_bootstrap_vars_schema: ANSIBLE_VARS_SCHEMA,
|
|
221
|
+
hub_bootstrap_vars_version: ANSIBLE_VARS_VERSION,
|
|
222
|
+
hub_bootstrap_release: manifest.release,
|
|
223
|
+
hub_bootstrap_channel: manifest.channel,
|
|
224
|
+
hub_bootstrap_minimum_version: manifest.minimumVersion,
|
|
225
|
+
hub_bootstrap_manifest_url: `${base}/${MANIFEST_FILE}`,
|
|
226
|
+
hub_bootstrap_artifacts: artifacts,
|
|
227
|
+
hub_bootstrap_harness_pins: manifest.harnesses.map((p) => ({ harness: p.harness, version: p.version, target: p.target, sha256: p.sha256 })),
|
|
228
|
+
hub_bootstrap_profile: profile,
|
|
229
|
+
hub_bootstrap_profile_digest: digestOf(profile),
|
|
230
|
+
hub_bootstrap_expected_plan: expected,
|
|
231
|
+
hub_bootstrap_escalate: options.escalate === true,
|
|
232
|
+
hub_bootstrap_confirm_activation: options.confirmActivation === true,
|
|
233
|
+
hub_bootstrap_allow_downgrade: options.allowDowngrade === true,
|
|
234
|
+
hub_bootstrap_require_ready: options.requireReady !== false,
|
|
235
|
+
hub_bootstrap_apply_require_ready: options.applyRequireReady === true,
|
|
236
|
+
}
|
|
237
|
+
const secrets = findSecrets(vars)
|
|
238
|
+
if (secrets.length > 0) return refuse("invalid", `refusing to render credential-shaped values at ${secrets.join(", ")}`, secrets[0])
|
|
239
|
+
return { ok: true, vars, yaml: renderYAML(vars) }
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* The vars as YAML: one `key: <canonical JSON>` line per var, in a fixed order (JSON is YAML, so any YAML loader
|
|
244
|
+
* reads it back exactly, and the same vars always render the same bytes).
|
|
245
|
+
*/
|
|
246
|
+
export function renderYAML(vars: RoleVars): string {
|
|
247
|
+
const lines = [
|
|
248
|
+
"---",
|
|
249
|
+
"# Vars for the hub_bootstrap Ansible role, rendered by hubctl. Regenerate instead of editing by hand:",
|
|
250
|
+
"# the profile and the pins must stay exactly what was previewed and signed.",
|
|
251
|
+
...VAR_ORDER.map((key) => `${key}: ${canonicalJSON(vars[key])}`),
|
|
252
|
+
]
|
|
253
|
+
return `${lines.join("\n")}\n`
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export interface RolePaths {
|
|
257
|
+
/** The staged, digest-checked hubctl the role runs. */
|
|
258
|
+
readonly hubctl: string
|
|
259
|
+
readonly profile: string
|
|
260
|
+
readonly preview: string
|
|
261
|
+
readonly manifest: string
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export interface RoleCommands {
|
|
265
|
+
readonly preview: readonly string[]
|
|
266
|
+
readonly apply: readonly string[]
|
|
267
|
+
readonly verify: readonly string[]
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** The exact argv the role runs; the same words a person runs on the direct path. */
|
|
271
|
+
export function directCommands(vars: RoleVars, paths: RolePaths): RoleCommands {
|
|
272
|
+
const consent = (Object.keys(ROLE_FLAGS) as (keyof typeof ROLE_FLAGS)[]).filter((key) => vars[key] === true).map((key) => ROLE_FLAGS[key])
|
|
273
|
+
const applyFlags = (Object.keys(ROLE_APPLY_FLAGS) as (keyof typeof ROLE_APPLY_FLAGS)[]).filter((key) => vars[key] === true).map((key) => ROLE_APPLY_FLAGS[key])
|
|
274
|
+
return {
|
|
275
|
+
preview: [paths.hubctl, "setup", "preview", "--profile", paths.profile, "--manifest", paths.manifest, "--out", paths.preview, "--json"],
|
|
276
|
+
apply: [paths.hubctl, "setup", "apply", "--preview", paths.preview, "--manifest", paths.manifest, ...consent, ...applyFlags, "--json"],
|
|
277
|
+
verify: [paths.hubctl, "setup", "verify", "--profile", paths.profile, "--json"],
|
|
278
|
+
}
|
|
279
|
+
}
|