@forgezero/agent 0.1.9 → 0.1.11
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/README.md +25 -2
- package/dist/cli/agent-install.d.ts +82 -0
- package/dist/cli/agent-install.test.d.ts +1 -0
- package/dist/cli/custody.d.ts +35 -0
- package/dist/cli/genesis.d.ts +79 -0
- package/dist/cli/index.d.ts +12 -0
- package/dist/cli/options.test.d.ts +1 -0
- package/dist/cli/run.d.ts +92 -0
- package/dist/cli/run.test.d.ts +1 -0
- package/dist/deployment-pull.d.ts +2 -1
- package/dist/deployment.d.ts +14 -0
- package/dist/fz-agent.js +49 -7
- package/dist/fz.js +1270 -0
- package/dist/index.d.ts +1 -1
- package/dist/provisioning-pull.d.ts +2 -0
- package/dist/provisioning-pull.js +9 -1
- package/package.json +8 -6
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @forgezero/agent
|
|
2
2
|
|
|
3
|
-
**`fz
|
|
3
|
+
**`fz` controls the machine; `fz-agent` runs its managed work.**
|
|
4
4
|
|
|
5
5
|
The agent keeps a project-scoped copy of your vault in RAM and answers over a
|
|
6
6
|
unix socket. `@forgezero/vault` prefers that socket over an API key whenever it
|
|
@@ -10,9 +10,24 @@ from it.
|
|
|
10
10
|
|
|
11
11
|
```bash
|
|
12
12
|
bun add -g @forgezero/agent # or let `fz agent install` do it
|
|
13
|
+
fz --help
|
|
13
14
|
```
|
|
14
15
|
|
|
15
|
-
|
|
16
|
+
The one public package installs both commands. Keeping bootstrap and the daemon
|
|
17
|
+
in one version prevents a newly installed `fz` from provisioning a different
|
|
18
|
+
agent protocol.
|
|
19
|
+
|
|
20
|
+
The operator command launches a platform, runs custody ceremonies, inspects
|
|
21
|
+
status, wraps processes, and installs the daemon:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
fz keys
|
|
25
|
+
fz status
|
|
26
|
+
fz genesis --mode 2-of-3
|
|
27
|
+
fz unlock
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Install the service explicitly:
|
|
16
31
|
|
|
17
32
|
```bash
|
|
18
33
|
fz agent install --apply # writes a hardened systemd unit
|
|
@@ -104,6 +119,14 @@ to the keyed queue. A restart in either state resumes the exact commit; only an
|
|
|
104
119
|
awaited successful pipeline writes `deployed`. Tenant nodes receive the same
|
|
105
120
|
exact-commit request from the signed API claim path.
|
|
106
121
|
|
|
122
|
+
Repository read authorization is explicit per pipeline: public HTTPS, the
|
|
123
|
+
compute's systemd-sealed SSH deploy key, or a fine-grained HTTPS token selected
|
|
124
|
+
by a project-vault secret name. A signed claim contains the mode and secret name
|
|
125
|
+
only. The agent resolves the value from its scoped memory cache, limits the Git
|
|
126
|
+
header to the repository origin, never writes the token into a command, and
|
|
127
|
+
never exposes it to tenant pipeline steps. Automatic GitHub App token minting
|
|
128
|
+
can feed the same short-lived token mode later; it is not implemented today.
|
|
129
|
+
|
|
107
130
|
An operator may also force a deployment and await the complete result over the
|
|
108
131
|
private control socket:
|
|
109
132
|
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { type Capabilities, type ProvisionPlan } from '../provision';
|
|
2
|
+
/**
|
|
3
|
+
* `fz agent install` — run the provision plan on THIS machine.
|
|
4
|
+
*
|
|
5
|
+
* The plan itself is the agent's public `provision` subpath, with no transport, and
|
|
6
|
+
* that is the whole point: the platform runs the same steps over SSH when it
|
|
7
|
+
* enrols a compute. This file is the local executor and nothing else — it
|
|
8
|
+
* decides how to run a command here, never what the commands are.
|
|
9
|
+
*
|
|
10
|
+
* It was briefly the other thing. This module had its own machine detection
|
|
11
|
+
* calling `existsSync` directly and its own unit renderer, while the API had a
|
|
12
|
+
* `Check`-based preflight designed to run remotely. Two answers to "is this box
|
|
13
|
+
* ready" is one answer nobody can trust, and the local one drifts first because
|
|
14
|
+
* it is the one somebody runs while debugging.
|
|
15
|
+
*/
|
|
16
|
+
export type { ProvisionPlan };
|
|
17
|
+
/** Parse NAME=value pairs used only for non-secret unit coordinates and credential paths. */
|
|
18
|
+
export declare function parseAssignments(value: string | undefined): Record<string, string> | undefined;
|
|
19
|
+
/**
|
|
20
|
+
* Answer the capability checks by running their commands.
|
|
21
|
+
*
|
|
22
|
+
* Running the command rather than calling `existsSync` is deliberate even
|
|
23
|
+
* though this is the local path: it means the answer here and the answer over
|
|
24
|
+
* SSH come from the same test, so a box that reports `attested` to an operator
|
|
25
|
+
* cannot report `enrolled` to the platform.
|
|
26
|
+
*/
|
|
27
|
+
export declare function readCapabilities(run: (command: string) => Promise<{
|
|
28
|
+
stdout: string;
|
|
29
|
+
exitCode: number;
|
|
30
|
+
}>): Promise<Capabilities>;
|
|
31
|
+
/** Run a command locally. The SSH executor is the platform's half. */
|
|
32
|
+
export declare function localRunner(command: string): Promise<{
|
|
33
|
+
stdout: string;
|
|
34
|
+
exitCode: number;
|
|
35
|
+
}>;
|
|
36
|
+
export interface InstallOptions {
|
|
37
|
+
capabilities: Capabilities;
|
|
38
|
+
socketPath: string;
|
|
39
|
+
seedPath: string;
|
|
40
|
+
seedCredentialPath?: string;
|
|
41
|
+
gitCredentialPath?: string;
|
|
42
|
+
gitPublicKeyPath?: string;
|
|
43
|
+
generateGitIdentity?: boolean;
|
|
44
|
+
controlSocketPath?: string;
|
|
45
|
+
repository?: string;
|
|
46
|
+
branch?: string;
|
|
47
|
+
role?: string;
|
|
48
|
+
deployRoot?: string;
|
|
49
|
+
publicApiUrl?: string;
|
|
50
|
+
deploymentEnvironment?: Record<string, string>;
|
|
51
|
+
deploymentCredentials?: Record<string, string>;
|
|
52
|
+
pullDeployments?: boolean;
|
|
53
|
+
binPath?: string;
|
|
54
|
+
sourceBinPath?: string;
|
|
55
|
+
user?: string;
|
|
56
|
+
apiUrl?: string;
|
|
57
|
+
project?: string;
|
|
58
|
+
environment?: string;
|
|
59
|
+
enrolTokenSourcePath?: string;
|
|
60
|
+
enrolTokenCredentialPath?: string;
|
|
61
|
+
enrolStatePath?: string;
|
|
62
|
+
nodeLabel?: string;
|
|
63
|
+
}
|
|
64
|
+
export declare function planInstall(options: InstallOptions): ProvisionPlan;
|
|
65
|
+
/** Execute the same ordered plan the control plane executes over SSH. */
|
|
66
|
+
export declare function applyPlan(plan: ProvisionPlan, run: (command: string) => Promise<{
|
|
67
|
+
stdout: string;
|
|
68
|
+
exitCode: number;
|
|
69
|
+
}>): Promise<readonly {
|
|
70
|
+
label: string;
|
|
71
|
+
command: string;
|
|
72
|
+
exitCode: number;
|
|
73
|
+
}[]>;
|
|
74
|
+
/**
|
|
75
|
+
* What a reader needs to see before running any of it.
|
|
76
|
+
*
|
|
77
|
+
* Printed rather than executed by default. Installing a system service that
|
|
78
|
+
* holds key material should not happen because somebody typed a subcommand, and
|
|
79
|
+
* an operator who reads the unit first is one who can notice it is about to run
|
|
80
|
+
* as the wrong user.
|
|
81
|
+
*/
|
|
82
|
+
export declare function renderPlan(plan: ProvisionPlan): string;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type AgentIdentity } from '@forgezero/runtime/ssh-agent';
|
|
2
|
+
/**
|
|
3
|
+
* Choosing an SSH key for custody, safely.
|
|
4
|
+
*
|
|
5
|
+
* A real agent is not a clean room. It commonly holds forwarded keys whose
|
|
6
|
+
* upstream connection is gone, and confirm-on-use keys that wait for a human at
|
|
7
|
+
* a terminal nobody is sitting at. Neither fails — both **hang**, which during a
|
|
8
|
+
* genesis ceremony looks exactly like the platform being broken.
|
|
9
|
+
*
|
|
10
|
+
* So every key is probed under a timeout before it is offered, and the one
|
|
11
|
+
* actually chosen is proved deterministic before it is trusted with a share. An
|
|
12
|
+
* agent that signs differently twice would seal a share that can never be
|
|
13
|
+
* reopened, and that failure would surface only during recovery.
|
|
14
|
+
*/
|
|
15
|
+
export declare const PROBE_TIMEOUT_MS = 2000;
|
|
16
|
+
export interface UsableIdentity extends AgentIdentity {
|
|
17
|
+
/** Milliseconds the agent took to sign. Slow keys are usually forwarded. */
|
|
18
|
+
responseMs: number;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Every Ed25519 key in the agent that actually responds.
|
|
22
|
+
*
|
|
23
|
+
* Returns an empty array rather than throwing when the agent holds nothing
|
|
24
|
+
* usable — the caller has a better error to give than this function does.
|
|
25
|
+
*/
|
|
26
|
+
export declare function usableIdentities(socketPath?: string): Promise<UsableIdentity[]>;
|
|
27
|
+
/**
|
|
28
|
+
* Derive the custody key for a chosen identity, having proved it reproduces.
|
|
29
|
+
*
|
|
30
|
+
* `assertDeterministic` signs twice and compares. It costs one extra signature
|
|
31
|
+
* and removes the only failure mode that is invisible until recovery.
|
|
32
|
+
*/
|
|
33
|
+
export declare function custodyKeyFor(identity: AgentIdentity, socketPath?: string): Promise<Uint8Array>;
|
|
34
|
+
/** Match a key by fingerprint prefix, comment, or 1-based index. */
|
|
35
|
+
export declare function selectIdentity(identities: UsableIdentity[], selector: string): UsableIdentity | null;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { type SealedShare } from '@forgezero/runtime/custody-share';
|
|
2
|
+
import type { UsableIdentity } from './custody';
|
|
3
|
+
/**
|
|
4
|
+
* The genesis ceremony, driven from a terminal.
|
|
5
|
+
*
|
|
6
|
+
* Every value that matters is produced HERE and only sealed material crosses
|
|
7
|
+
* the wire:
|
|
8
|
+
*
|
|
9
|
+
* phrase generated locally, printed once, never transmitted or stored
|
|
10
|
+
* ssh key derived locally from an agent signature, never transmitted
|
|
11
|
+
*
|
|
12
|
+
* The API receives the derived 32-byte keys because that is what seals a share,
|
|
13
|
+
* and it holds them for exactly the length of one request. Nothing here gives
|
|
14
|
+
* the CLI a privileged path — it calls the same endpoints the browser does, and
|
|
15
|
+
* a bypass that existed for bootstrapping would exist for an attacker too.
|
|
16
|
+
*/
|
|
17
|
+
export interface Transport {
|
|
18
|
+
(path: string, init?: {
|
|
19
|
+
method?: string;
|
|
20
|
+
body?: unknown;
|
|
21
|
+
}): Promise<{
|
|
22
|
+
status: number;
|
|
23
|
+
body: unknown;
|
|
24
|
+
}>;
|
|
25
|
+
}
|
|
26
|
+
export interface GenesisResult {
|
|
27
|
+
ceremonyKey: string;
|
|
28
|
+
phrase: string[];
|
|
29
|
+
fingerprint: string;
|
|
30
|
+
activated: boolean;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Run a single-custodian genesis.
|
|
34
|
+
*
|
|
35
|
+
* Deliberately limited to the operator running the command. A multi-custodian
|
|
36
|
+
* genesis needs each person at their own terminal with their own agent, which
|
|
37
|
+
* is a coordination problem rather than a code one — and pretending to do it
|
|
38
|
+
* from one shell would mean one machine briefly holding every share.
|
|
39
|
+
*/
|
|
40
|
+
export declare function runGenesis(args: {
|
|
41
|
+
api: Transport;
|
|
42
|
+
modeId: string;
|
|
43
|
+
userKey: string;
|
|
44
|
+
email: string;
|
|
45
|
+
identity: UsableIdentity;
|
|
46
|
+
/** The `plt_` token setup.sh wrote. Required — see below. */
|
|
47
|
+
token: string;
|
|
48
|
+
displayName?: string;
|
|
49
|
+
socketPath?: string;
|
|
50
|
+
onStep?: (message: string) => void;
|
|
51
|
+
}): Promise<GenesisResult>;
|
|
52
|
+
/**
|
|
53
|
+
* Reconstruct the seed and unlock.
|
|
54
|
+
*
|
|
55
|
+
* A restart genuinely locks the vault — residency is derived from memory, never
|
|
56
|
+
* a stored flag — so this is the routine every reboot needs, not an exceptional
|
|
57
|
+
* recovery path.
|
|
58
|
+
*/
|
|
59
|
+
export declare function runUnlock(args: {
|
|
60
|
+
api: Transport;
|
|
61
|
+
userKey: string;
|
|
62
|
+
identity?: UsableIdentity;
|
|
63
|
+
phrase?: string[];
|
|
64
|
+
socketPath?: string;
|
|
65
|
+
/**
|
|
66
|
+
* This custodian's sealed envelope, read from the ceremony.
|
|
67
|
+
*
|
|
68
|
+
* Supplied by the caller because only they know which ceremony they are
|
|
69
|
+
* unlocking. Opening it is a local step now, so no factor travels.
|
|
70
|
+
*/
|
|
71
|
+
sealed?: SealedShare;
|
|
72
|
+
/** The salt the phrase wrapping key was derived over. */
|
|
73
|
+
phraseSalt?: string;
|
|
74
|
+
onStep?: (message: string) => void;
|
|
75
|
+
}): Promise<{
|
|
76
|
+
fingerprint: string;
|
|
77
|
+
}>;
|
|
78
|
+
/** Resolve `--key` to a usable identity, with an actionable error if it cannot. */
|
|
79
|
+
export declare function resolveIdentity(selector: string | undefined, socketPath?: string): Promise<UsableIdentity>;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { type AgentIdentity } from '@forgezero/runtime/ssh-agent';
|
|
3
|
+
/**
|
|
4
|
+
* What every CLI request carries.
|
|
5
|
+
*
|
|
6
|
+
* Extracted and exported because the `origin` header below is load-bearing and
|
|
7
|
+
* was invisible: `fz --help` proved the binary starts, and every command that
|
|
8
|
+
* CHANGED anything was answered 403 by the CSRF rule with nothing in between to
|
|
9
|
+
* notice. A header nobody can see is a header nobody maintains.
|
|
10
|
+
*/
|
|
11
|
+
export declare function requestHeaders(apiBase: string, cookie: string | null): Record<string, string>;
|
|
12
|
+
export declare function useCustodyIdentity(identity: AgentIdentity, socketPath?: string): void;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Operator process injection, shipped beside the agent command.
|
|
3
|
+
*
|
|
4
|
+
* `fz run -- <command>` — hand secrets to a process that cannot ask for them.
|
|
5
|
+
*
|
|
6
|
+
* This is the FALLBACK, and it is worth saying so at the top of the file. The
|
|
7
|
+
* agent serves secrets over a unix socket and anything using `@forgezero/vault`
|
|
8
|
+
* reads them there, which means a rotation reaches a running process. An
|
|
9
|
+
* injected environment cannot rotate: a process started before a rotation runs
|
|
10
|
+
* on the old value until somebody restarts it, and nothing tells them.
|
|
11
|
+
*
|
|
12
|
+
* It exists because a great deal of software will never read a socket, and
|
|
13
|
+
* "rewrite your build tool" is not an adoption path.
|
|
14
|
+
*
|
|
15
|
+
* ## What an environment variable costs
|
|
16
|
+
*
|
|
17
|
+
* On Linux `/proc/<pid>/environ` is readable by the same user, and any child
|
|
18
|
+
* inherits everything. That is the standard trade and it is not hidden here:
|
|
19
|
+
* the whole point of the socket path is that it does not make that trade.
|
|
20
|
+
*
|
|
21
|
+
* Values never reach argv, because argv is world-readable in `ps` — which is a
|
|
22
|
+
* different and much worse exposure than the environment.
|
|
23
|
+
*/
|
|
24
|
+
export declare class RunError extends Error {
|
|
25
|
+
readonly code: 'NO_COMMAND' | 'NO_SEPARATOR' | 'BAD_NAME';
|
|
26
|
+
constructor(code: 'NO_COMMAND' | 'NO_SEPARATOR' | 'BAD_NAME', message: string);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Split fz's own arguments from the child's.
|
|
30
|
+
*
|
|
31
|
+
* `--` is the boundary and it is not optional. Without it `fz run node --watch`
|
|
32
|
+
* would have fz eat `--watch`, and the failure is a flag that silently does not
|
|
33
|
+
* reach the program somebody is debugging.
|
|
34
|
+
*
|
|
35
|
+
* Everything after the FIRST separator belongs to the child, separators
|
|
36
|
+
* included: `fz run -- sh -c 'x -- y'` has to pass the inner one through.
|
|
37
|
+
*/
|
|
38
|
+
export declare function splitAtSeparator(argv: readonly string[]): {
|
|
39
|
+
own: string[];
|
|
40
|
+
command: string[];
|
|
41
|
+
};
|
|
42
|
+
export interface MergeOptions {
|
|
43
|
+
/** Keep an existing value when both sides have the name. Default false. */
|
|
44
|
+
preserveEnv?: boolean;
|
|
45
|
+
}
|
|
46
|
+
export interface MergeResult {
|
|
47
|
+
env: Record<string, string>;
|
|
48
|
+
/** Names present in both. Reported so a collision is never silent. */
|
|
49
|
+
collisions: string[];
|
|
50
|
+
/** Names the shell cannot express, refused rather than mangled. */
|
|
51
|
+
refused: string[];
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Merge vault values into an environment.
|
|
55
|
+
*
|
|
56
|
+
* The vault wins by default: a caller running `fz run` is asking for vault
|
|
57
|
+
* values, and quietly preferring a stale variable already in the shell is how
|
|
58
|
+
* somebody debugs a wrong credential for an hour.
|
|
59
|
+
*
|
|
60
|
+
* `--preserve-env` inverts it for the case where a wrapper genuinely wants to
|
|
61
|
+
* override. Either way the COLLISIONS are returned so the caller can say which
|
|
62
|
+
* names were affected. Silence is what makes this confusing, not the direction.
|
|
63
|
+
*/
|
|
64
|
+
export declare function mergeEnvironment(base: Record<string, string | undefined>, secrets: Record<string, string>, options?: MergeOptions): MergeResult;
|
|
65
|
+
/**
|
|
66
|
+
* What to print before handing over.
|
|
67
|
+
*
|
|
68
|
+
* Names only. A wrapper that echoed values would put every secret into whatever
|
|
69
|
+
* captured the build log, which is usually the one place they are kept longest.
|
|
70
|
+
*/
|
|
71
|
+
export declare function describeInjection(result: MergeResult, count: number): string;
|
|
72
|
+
/**
|
|
73
|
+
* Turn a child's exit into this process's exit.
|
|
74
|
+
*
|
|
75
|
+
* A wrapper that always exits 0 breaks every CI pipeline it is put in front of,
|
|
76
|
+
* silently, by turning a failed build into a passing one. A signal death is
|
|
77
|
+
* reported the way a shell reports it — 128 plus the signal number — so
|
|
78
|
+
* `fz run -- make` behaves like `make` for anything reading the code.
|
|
79
|
+
*/
|
|
80
|
+
export declare function exitCodeFor(status: {
|
|
81
|
+
code: number | null;
|
|
82
|
+
signal: string | null;
|
|
83
|
+
}): number;
|
|
84
|
+
/**
|
|
85
|
+
* Start the child and become its exit code.
|
|
86
|
+
*
|
|
87
|
+
* Separated from the command handler so it can be driven by a test with real
|
|
88
|
+
* processes. The interesting behaviour is entirely here — inherited stdio,
|
|
89
|
+
* forwarded signals, propagated status — and none of it is provable against a
|
|
90
|
+
* mock.
|
|
91
|
+
*/
|
|
92
|
+
export declare function spawnWith(command: readonly string[], env: Record<string, string>, report?: (line: string) => void): Promise<number>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { NodeKeyPair } from '@forgezero/runtime/identity';
|
|
2
|
-
import type { DeploymentManager, DeploymentResult } from './deployment';
|
|
2
|
+
import type { DeploymentManager, DeploymentResult, GitSourceAuth } from './deployment';
|
|
3
3
|
export interface RemoteDeploymentClaim {
|
|
4
4
|
runKey: string;
|
|
5
5
|
pipelineKey: string;
|
|
@@ -12,6 +12,7 @@ export interface RemoteDeploymentClaim {
|
|
|
12
12
|
branch: string;
|
|
13
13
|
role: string;
|
|
14
14
|
knownHosts?: string;
|
|
15
|
+
auth?: GitSourceAuth;
|
|
15
16
|
};
|
|
16
17
|
}
|
|
17
18
|
export interface DeploymentPullOptions {
|
package/dist/deployment.d.ts
CHANGED
|
@@ -27,6 +27,19 @@ export interface DeploymentResult {
|
|
|
27
27
|
ok: boolean;
|
|
28
28
|
phases: readonly RunResult[];
|
|
29
29
|
}
|
|
30
|
+
/**
|
|
31
|
+
* How the credential-bearing agent may read one server-owned Git source.
|
|
32
|
+
* Secret values never appear in a deployment claim or checked-in pipeline.
|
|
33
|
+
*/
|
|
34
|
+
export type GitSourceAuth = {
|
|
35
|
+
kind: 'public';
|
|
36
|
+
} | {
|
|
37
|
+
kind: 'node-ssh';
|
|
38
|
+
} | {
|
|
39
|
+
kind: 'vault-token';
|
|
40
|
+
secret: string;
|
|
41
|
+
username?: string;
|
|
42
|
+
};
|
|
30
43
|
export interface DeploymentOptions {
|
|
31
44
|
/** Queue key. Same project/environment is serial; other managers may run in parallel. */
|
|
32
45
|
key: string;
|
|
@@ -35,6 +48,7 @@ export interface DeploymentOptions {
|
|
|
35
48
|
role: string;
|
|
36
49
|
root: string;
|
|
37
50
|
publicApiUrl?: string;
|
|
51
|
+
sourceAuth?: GitSourceAuth;
|
|
38
52
|
gitCredentialPath?: string;
|
|
39
53
|
knownHostsPath?: string;
|
|
40
54
|
/** Server-owned, operator-pinned host keys for a dynamically assigned source. */
|
package/dist/fz-agent.js
CHANGED
|
@@ -448,10 +448,39 @@ function createDeploymentManager(options) {
|
|
|
448
448
|
renameSync(next, knownHostsPath);
|
|
449
449
|
chmodSync2(knownHostsPath, 384);
|
|
450
450
|
};
|
|
451
|
-
const gitEnvironment = () => {
|
|
452
|
-
|
|
451
|
+
const gitEnvironment = async () => {
|
|
452
|
+
const https = /^https:\/\//i.test(options.repository);
|
|
453
|
+
const auth = options.sourceAuth ?? (https ? { kind: "public" } : { kind: "node-ssh" });
|
|
454
|
+
if (auth.kind === "public") {
|
|
455
|
+
if (!https)
|
|
456
|
+
throw new DeploymentError("SOURCE_FAILED", "Public Git sources must use HTTPS.");
|
|
453
457
|
return { GIT_TERMINAL_PROMPT: "0" };
|
|
454
458
|
}
|
|
459
|
+
if (auth.kind === "vault-token") {
|
|
460
|
+
if (!https)
|
|
461
|
+
throw new DeploymentError("SOURCE_FAILED", "Vault Git tokens may only be sent to HTTPS sources.");
|
|
462
|
+
if (!/^[A-Z_][A-Z0-9_]{0,127}$/.test(auth.secret)) {
|
|
463
|
+
throw new DeploymentError("SOURCE_FAILED", "The Git token secret name is invalid.");
|
|
464
|
+
}
|
|
465
|
+
if (!options.cache) {
|
|
466
|
+
throw new DeploymentError("SECRET_MISSING", `Git source credential ${auth.secret} is unavailable.`);
|
|
467
|
+
}
|
|
468
|
+
const username = auth.username ?? "x-access-token";
|
|
469
|
+
if (!/^[A-Za-z0-9._@+-]{1,128}$/.test(username)) {
|
|
470
|
+
throw new DeploymentError("SOURCE_FAILED", "The Git token username is invalid.");
|
|
471
|
+
}
|
|
472
|
+
const token = await options.cache.get(auth.secret);
|
|
473
|
+
if (!token || token.length > 8192 || /[\r\n\0]/.test(token)) {
|
|
474
|
+
throw new DeploymentError("SOURCE_FAILED", `Git source credential ${auth.secret} is malformed.`);
|
|
475
|
+
}
|
|
476
|
+
const origin = new URL(options.repository).origin;
|
|
477
|
+
return {
|
|
478
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
479
|
+
GIT_CONFIG_COUNT: "1",
|
|
480
|
+
GIT_CONFIG_KEY_0: `http.${origin}/.extraHeader`,
|
|
481
|
+
GIT_CONFIG_VALUE_0: `Authorization: Basic ${Buffer.from(`${username}:${token}`).toString("base64")}`
|
|
482
|
+
};
|
|
483
|
+
}
|
|
455
484
|
if (!gitCredentialPath) {
|
|
456
485
|
throw new DeploymentError("SOURCE_FAILED", "No Git deploy-key credential was loaded for the agent.");
|
|
457
486
|
}
|
|
@@ -474,7 +503,7 @@ function createDeploymentManager(options) {
|
|
|
474
503
|
}
|
|
475
504
|
const stamp = `${new Date(now()).toISOString().replace(/[-:.TZ]/g, "")}-${process.pid}-${randomUUID().slice(0, 8)}`;
|
|
476
505
|
const release = join(options.root, "releases", stamp);
|
|
477
|
-
const env = gitEnvironment();
|
|
506
|
+
const env = await gitEnvironment();
|
|
478
507
|
await checked({
|
|
479
508
|
command: `test -d ${quote(join(options.root, "releases"))} && test -w ${quote(join(options.root, "releases"))}`
|
|
480
509
|
}, "SOURCE_FAILED");
|
|
@@ -565,7 +594,7 @@ function createDeploymentManager(options) {
|
|
|
565
594
|
async latestRevision() {
|
|
566
595
|
const result = await checked({
|
|
567
596
|
command: `git ls-remote --exit-code ${quote(options.repository)} ${quote(`refs/heads/${options.branch}`)}`,
|
|
568
|
-
env: gitEnvironment()
|
|
597
|
+
env: await gitEnvironment()
|
|
569
598
|
}, "SOURCE_FAILED");
|
|
570
599
|
const revision = result.output.trim().split(/\s+/)[0] ?? "";
|
|
571
600
|
if (!/^[a-f0-9]{40}$/i.test(revision)) {
|
|
@@ -1353,7 +1382,15 @@ async function pullProvisioningOnce(options) {
|
|
|
1353
1382
|
if (options.metalPreflight) {
|
|
1354
1383
|
const report = options.metalPreflight();
|
|
1355
1384
|
const accepted = await postSignedNode(options, "v1/metal/preflight", report);
|
|
1356
|
-
options.
|
|
1385
|
+
if (options.metalHostname && accepted.hostname !== options.metalHostname) {
|
|
1386
|
+
throw new Error(`metal agent: configured inventory hostname ${options.metalHostname} is bound as ${accepted.hostname}; refusing work.`);
|
|
1387
|
+
}
|
|
1388
|
+
options.onEvent?.("preflight", {
|
|
1389
|
+
...report,
|
|
1390
|
+
hostname: accepted.hostname,
|
|
1391
|
+
ready: accepted.ready,
|
|
1392
|
+
state: accepted.state
|
|
1393
|
+
});
|
|
1357
1394
|
if (!accepted.ready)
|
|
1358
1395
|
return { status: "idle" };
|
|
1359
1396
|
}
|
|
@@ -2633,7 +2670,7 @@ async function applyMetalIsolation(profile, exec = defaultExec) {
|
|
|
2633
2670
|
}
|
|
2634
2671
|
|
|
2635
2672
|
// src/index.ts
|
|
2636
|
-
var VERSION = "0.1.
|
|
2673
|
+
var VERSION = "0.1.11";
|
|
2637
2674
|
function loadOrCreateSeed(path) {
|
|
2638
2675
|
if (existsSync9(path)) {
|
|
2639
2676
|
const seed2 = new Uint8Array(Buffer.from(readFileSync5(path, "utf8").trim(), "base64url"));
|
|
@@ -2831,6 +2868,8 @@ if (import.meta.main) {
|
|
|
2831
2868
|
if (process.env.FZ_AGENT_ROLE === "metal") {
|
|
2832
2869
|
if (!process.env.FZ_API)
|
|
2833
2870
|
throw new Error("metal agent requires FZ_API");
|
|
2871
|
+
if (!process.env.FZ_METAL_HOSTNAME)
|
|
2872
|
+
throw new Error("metal agent requires FZ_METAL_HOSTNAME");
|
|
2834
2873
|
const seed = process.env.FZ_SEED_CREDENTIAL ? loadSeedCredential(process.env.FZ_SEED_CREDENTIAL) : loadOrCreateSeed(process.env.FZ_SEED_PATH ?? DEFAULT_SEED_PATH);
|
|
2835
2874
|
const keys2 = deriveKeysFromSeed(seed);
|
|
2836
2875
|
const nodeKey2 = process.env.FZ_NODE_KEY ?? keys2.ed25519.publicKey;
|
|
@@ -2838,6 +2877,7 @@ if (import.meta.main) {
|
|
|
2838
2877
|
apiUrl: process.env.FZ_API,
|
|
2839
2878
|
nodeKey: nodeKey2,
|
|
2840
2879
|
keys: keys2,
|
|
2880
|
+
metalHostname: process.env.FZ_METAL_HOSTNAME,
|
|
2841
2881
|
run: (claim) => requestMetalProvision(claim, process.env.FZ_METAL_HELPER_SOCKET ?? DEFAULT_METAL_HELPER_SOCKET),
|
|
2842
2882
|
metalPreflight: () => ({
|
|
2843
2883
|
snpHost: existsSync9("/dev/sev"),
|
|
@@ -2974,6 +3014,7 @@ if (import.meta.main) {
|
|
|
2974
3014
|
repository: source.repository,
|
|
2975
3015
|
branch: source.branch,
|
|
2976
3016
|
role: source.role,
|
|
3017
|
+
sourceAuth: source.auth,
|
|
2977
3018
|
root,
|
|
2978
3019
|
publicApiUrl: process.env.FZ_PUBLIC_API_URL,
|
|
2979
3020
|
environment: Object.fromEntries((process.env.FZ_DEPLOY_ENV_NAMES ?? "").split(",").filter(Boolean).map((name) => {
|
|
@@ -3021,7 +3062,8 @@ if (import.meta.main) {
|
|
|
3021
3062
|
claim.pipelineKey,
|
|
3022
3063
|
claim.source.repository,
|
|
3023
3064
|
claim.source.branch,
|
|
3024
|
-
claim.source.role
|
|
3065
|
+
claim.source.role,
|
|
3066
|
+
JSON.stringify(claim.source.auth ?? null)
|
|
3025
3067
|
].join("\x00");
|
|
3026
3068
|
const existing = managers.get(cacheKey);
|
|
3027
3069
|
if (existing)
|