@pasko70/pibo 2.3.0 → 2.4.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/apps/vscode-artifacts/latest.vsix +0 -0
- package/dist/apps/vscode-artifacts/{pibo-vscode-ext-2.3.0.vsix → pibo-vscode-ext-2.4.0.vsix} +0 -0
- package/dist/compute/cli.js +13 -0
- package/dist/compute/pool/artifacts.js +116 -0
- package/dist/compute/pool/cli.js +156 -0
- package/dist/compute/pool/config.js +101 -0
- package/dist/compute/pool/docker.js +157 -0
- package/dist/compute/pool/seeds.js +201 -0
- package/dist/compute/pool/service.js +402 -0
- package/dist/compute/pool/store.js +239 -0
- package/dist/compute/pool/types.js +1 -0
- package/dist/resources/lifecycle.js +22 -2
- package/dist/resources/reaper.js +1 -0
- package/dist/web/channel.js +9 -3
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
|
Binary file
|
package/dist/apps/vscode-artifacts/{pibo-vscode-ext-2.3.0.vsix → pibo-vscode-ext-2.4.0.vsix}
RENAMED
|
Binary file
|
package/dist/compute/cli.js
CHANGED
|
@@ -199,13 +199,26 @@ export async function runComputeCli(argv) {
|
|
|
199
199
|
Examples:
|
|
200
200
|
$ pibo compute spawn
|
|
201
201
|
$ pibo compute dev spawn --worktree my-fix
|
|
202
|
+
$ pibo compute pool status
|
|
202
203
|
$ pibo compute list
|
|
203
204
|
$ pibo compute release pibo-worker-abc123
|
|
204
205
|
|
|
205
206
|
Next:
|
|
206
207
|
$ pibo compute spawn --help
|
|
207
208
|
$ pibo compute dev --help
|
|
209
|
+
$ pibo compute pool --help
|
|
208
210
|
`);
|
|
211
|
+
program
|
|
212
|
+
.command("pool")
|
|
213
|
+
.description("Lease isolated Pibo deployment slots")
|
|
214
|
+
.helpOption(false)
|
|
215
|
+
.allowUnknownOption(true)
|
|
216
|
+
.allowExcessArguments(true)
|
|
217
|
+
.argument("[args...]")
|
|
218
|
+
.action(async (args) => {
|
|
219
|
+
const { runComputePoolCli } = await import("./pool/cli.js");
|
|
220
|
+
await runComputePoolCli([argv[0] ?? "node", "pibo compute pool", ...args]);
|
|
221
|
+
});
|
|
209
222
|
program
|
|
210
223
|
.command("spawn")
|
|
211
224
|
.description("Create a one-time worker from the current Docker image")
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { createReadStream, existsSync } from "node:fs";
|
|
3
|
+
import { lstat, mkdir, readFile, readdir, readlink, rename, rm, utimes, writeFile } from "node:fs/promises";
|
|
4
|
+
import { basename, resolve } from "node:path";
|
|
5
|
+
import { execFile } from "node:child_process";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
const PACKAGE_RELATIVE_PATH = "node_modules/@pasko70/pibo/package.json";
|
|
9
|
+
const BINARY_RELATIVE_PATH = "node_modules/@pasko70/pibo/dist/bin/pibo.js";
|
|
10
|
+
export async function ensureDeploymentArtifact(input) {
|
|
11
|
+
if (Boolean(input.archivePath) === Boolean(input.runtimePath)) {
|
|
12
|
+
throw new Error("Provide exactly one of archivePath or runtimePath");
|
|
13
|
+
}
|
|
14
|
+
if (input.runtimePath)
|
|
15
|
+
return inspectRuntimeArtifact(resolve(input.runtimePath));
|
|
16
|
+
const archivePath = resolve(input.archivePath);
|
|
17
|
+
if (!existsSync(archivePath))
|
|
18
|
+
throw new Error(`Deployment artifact archive does not exist: ${archivePath}`);
|
|
19
|
+
const sha256 = await sha256File(archivePath);
|
|
20
|
+
const artifactRoot = resolve(input.config.artifactRoot, sha256);
|
|
21
|
+
const runtimePath = resolve(artifactRoot, "runtime");
|
|
22
|
+
if (existsSync(resolve(runtimePath, BINARY_RELATIVE_PATH))) {
|
|
23
|
+
const now = new Date();
|
|
24
|
+
await utimes(artifactRoot, now, now);
|
|
25
|
+
return { ...(await inspectRuntimeArtifact(runtimePath)), sha256, reused: true };
|
|
26
|
+
}
|
|
27
|
+
await mkdir(input.config.artifactRoot, { recursive: true, mode: 0o700 });
|
|
28
|
+
const staging = resolve(input.config.artifactRoot, `.staging-${sha256}-${process.pid}`);
|
|
29
|
+
await rm(staging, { recursive: true, force: true });
|
|
30
|
+
await mkdir(resolve(staging, "runtime"), { recursive: true, mode: 0o700 });
|
|
31
|
+
await writeFile(resolve(staging, "runtime", "package.json"), '{"name":"pibo-deployment-pool-runtime","private":true}\n', { mode: 0o600 });
|
|
32
|
+
try {
|
|
33
|
+
await execFileAsync("npm", ["install", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund", archivePath], {
|
|
34
|
+
cwd: resolve(staging, "runtime"),
|
|
35
|
+
maxBuffer: 20 * 1024 * 1024,
|
|
36
|
+
});
|
|
37
|
+
if (!existsSync(resolve(staging, "runtime", BINARY_RELATIVE_PATH)))
|
|
38
|
+
throw new Error("Installed package does not contain the Pibo binary");
|
|
39
|
+
await writeFile(resolve(staging, "manifest.json"), `${JSON.stringify({ sha256, source: basename(archivePath), installedAt: new Date().toISOString() }, null, 2)}\n`, { mode: 0o600 });
|
|
40
|
+
if (existsSync(artifactRoot))
|
|
41
|
+
await rm(staging, { recursive: true, force: true });
|
|
42
|
+
else
|
|
43
|
+
await rename(staging, artifactRoot);
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
await rm(staging, { recursive: true, force: true });
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
return { ...(await inspectRuntimeArtifact(runtimePath)), sha256, reused: false };
|
|
50
|
+
}
|
|
51
|
+
export async function inspectRuntimeArtifact(runtimePath) {
|
|
52
|
+
const resolved = resolve(runtimePath);
|
|
53
|
+
const binaryPath = resolve(resolved, BINARY_RELATIVE_PATH);
|
|
54
|
+
if (!existsSync(binaryPath))
|
|
55
|
+
throw new Error(`Pibo runtime binary was not found: ${binaryPath}`);
|
|
56
|
+
let packageVersion;
|
|
57
|
+
let packageText = "";
|
|
58
|
+
try {
|
|
59
|
+
packageText = await readFile(resolve(resolved, PACKAGE_RELATIVE_PATH), "utf8");
|
|
60
|
+
const parsed = JSON.parse(packageText);
|
|
61
|
+
if (parsed.name !== "@pasko70/pibo")
|
|
62
|
+
throw new Error(`Unexpected package name in ${resolved}`);
|
|
63
|
+
if (typeof parsed.version === "string")
|
|
64
|
+
packageVersion = parsed.version;
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
if (error instanceof Error && error.message.startsWith("Unexpected package"))
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
const packageRoot = resolve(resolved, "node_modules/@pasko70/pibo");
|
|
71
|
+
return { sha256: await sha256Directory(packageRoot), runtimePath: resolved, binaryPath, packageVersion, reused: true };
|
|
72
|
+
}
|
|
73
|
+
async function sha256Directory(root) {
|
|
74
|
+
const hash = createHash("sha256");
|
|
75
|
+
await hashDirectoryEntries(hash, root, "");
|
|
76
|
+
return hash.digest("hex");
|
|
77
|
+
}
|
|
78
|
+
async function hashDirectoryEntries(hash, root, relativeDirectory) {
|
|
79
|
+
const directory = relativeDirectory ? resolve(root, relativeDirectory) : root;
|
|
80
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
81
|
+
entries.sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
|
|
82
|
+
for (const entry of entries) {
|
|
83
|
+
const relativePath = relativeDirectory ? `${relativeDirectory}/${entry.name}` : entry.name;
|
|
84
|
+
const absolutePath = resolve(root, relativePath);
|
|
85
|
+
const stats = await lstat(absolutePath);
|
|
86
|
+
if (entry.isDirectory()) {
|
|
87
|
+
hash.update(`directory\0${relativePath}\0${stats.mode & 0o777}\0`);
|
|
88
|
+
await hashDirectoryEntries(hash, root, relativePath);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (entry.isFile()) {
|
|
92
|
+
hash.update(`file\0${relativePath}\0${stats.mode & 0o777}\0${stats.size}\0`);
|
|
93
|
+
await hashFileContents(hash, absolutePath);
|
|
94
|
+
hash.update("\0");
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (entry.isSymbolicLink()) {
|
|
98
|
+
hash.update(`symlink\0${relativePath}\0${await readlink(absolutePath)}\0`);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
throw new Error(`Unsupported runtime artifact entry: ${absolutePath}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
async function hashFileContents(hash, path) {
|
|
105
|
+
await new Promise((resolvePromise, reject) => {
|
|
106
|
+
const stream = createReadStream(path);
|
|
107
|
+
stream.on("data", (chunk) => hash.update(chunk));
|
|
108
|
+
stream.once("end", resolvePromise);
|
|
109
|
+
stream.once("error", reject);
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
async function sha256File(path) {
|
|
113
|
+
const hash = createHash("sha256");
|
|
114
|
+
await hashFileContents(hash, path);
|
|
115
|
+
return hash.digest("hex");
|
|
116
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { ensureDeploymentArtifact } from "./artifacts.js";
|
|
3
|
+
import { resolveDeploymentPoolConfig } from "./config.js";
|
|
4
|
+
import { acquireDeployment, applyDeploymentPoolReapPlan, getDeploymentPoolDoctor, getDeploymentPoolStatus, listDeploymentArtifacts, planDeploymentPoolReap, releaseDeploymentLease, renewDeploymentLease, } from "./service.js";
|
|
5
|
+
function printJson(value) {
|
|
6
|
+
console.log(JSON.stringify(value, null, 2));
|
|
7
|
+
}
|
|
8
|
+
function parsePositiveInteger(value) {
|
|
9
|
+
const parsed = Number(value);
|
|
10
|
+
if (!Number.isInteger(parsed) || parsed < 1)
|
|
11
|
+
throw new Error("Value must be a positive integer");
|
|
12
|
+
return parsed;
|
|
13
|
+
}
|
|
14
|
+
function parseSeedMode(value) {
|
|
15
|
+
if (value === "full" || value === "medium" || value === "fresh")
|
|
16
|
+
return value;
|
|
17
|
+
throw new Error("Seed mode must be full, medium, or fresh");
|
|
18
|
+
}
|
|
19
|
+
function printDiscovery() {
|
|
20
|
+
console.log(`pibo compute pool - lease isolated Pibo deployment slots
|
|
21
|
+
|
|
22
|
+
Commands:
|
|
23
|
+
status Show slots and active leases
|
|
24
|
+
acquire Install an exact runtime into a free slot
|
|
25
|
+
renew <lease-id> Extend an active lease
|
|
26
|
+
release <lease-id> Stop and free a lease
|
|
27
|
+
reap Preview or apply expired-lease cleanup
|
|
28
|
+
doctor Check pool, Docker, seed, and container state
|
|
29
|
+
artifacts List installed checksum-addressed runtimes
|
|
30
|
+
seed Explain available seed modes
|
|
31
|
+
|
|
32
|
+
Next:
|
|
33
|
+
pibo compute pool acquire --help
|
|
34
|
+
`);
|
|
35
|
+
}
|
|
36
|
+
export async function runComputePoolCli(argv) {
|
|
37
|
+
if (argv.length <= 2 || argv[2] === "--help" || argv[2] === "-h") {
|
|
38
|
+
printDiscovery();
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const program = new Command();
|
|
42
|
+
program.name("pibo compute pool").description("Lease isolated Pibo deployment slots").showHelpAfterError();
|
|
43
|
+
program.command("status")
|
|
44
|
+
.option("--json", "Print machine-readable status")
|
|
45
|
+
.action((options) => {
|
|
46
|
+
const status = getDeploymentPoolStatus();
|
|
47
|
+
if (options.json)
|
|
48
|
+
printJson(status);
|
|
49
|
+
else {
|
|
50
|
+
console.log(`Deployment pool: ${status.active}/${status.maxActive} active, ${status.free} free`);
|
|
51
|
+
for (const slot of status.slots)
|
|
52
|
+
console.log(`${slot.id}\t${slot.state}\t${slot.publicUrl ?? "-"}\t${slot.lease?.holder ?? "-"}\t${slot.lease?.expiresAt ?? "-"}`);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
program.command("acquire")
|
|
56
|
+
.requiredOption("--holder <holder>", "Pibo Session ID or other stable holder")
|
|
57
|
+
.option("--artifact <path>", "Server-local npm package archive")
|
|
58
|
+
.option("--runtime <path>", "Server-local installed Pibo runtime directory")
|
|
59
|
+
.option("--seed <mode>", "Seed mode: full, medium, or fresh", parseSeedMode, "medium")
|
|
60
|
+
.option("--ttl-minutes <minutes>", "Lease lifetime", parsePositiveInteger)
|
|
61
|
+
.option("--commit <sha>", "Source commit metadata")
|
|
62
|
+
.option("--json", "Print machine-readable lease")
|
|
63
|
+
.addHelpText("after", `
|
|
64
|
+
Provide exactly one source:
|
|
65
|
+
pibo compute pool acquire --holder ps_... --artifact /path/pibo.tgz --seed medium
|
|
66
|
+
pibo compute pool acquire --holder ps_... --runtime /opt/pibo-candidates/name/commit/runtime --seed fresh
|
|
67
|
+
`)
|
|
68
|
+
.action(async (options) => {
|
|
69
|
+
const config = resolveDeploymentPoolConfig();
|
|
70
|
+
const artifact = await ensureDeploymentArtifact({ config, archivePath: options.artifact, runtimePath: options.runtime });
|
|
71
|
+
const lease = await acquireDeployment({ holder: options.holder, seedMode: options.seed, artifact, ttlMinutes: options.ttlMinutes, commit: options.commit, config });
|
|
72
|
+
if (options.json)
|
|
73
|
+
printJson(lease);
|
|
74
|
+
else {
|
|
75
|
+
console.log(`${lease.id}\t${lease.status}\t${lease.slotId}\t${lease.publicUrl ?? "-"}`);
|
|
76
|
+
console.log(`expires\t${lease.expiresAt}`);
|
|
77
|
+
console.log(`renew\tpibo compute pool renew ${lease.id} --holder ${lease.holder}`);
|
|
78
|
+
console.log(`release\tpibo compute pool release ${lease.id} --holder ${lease.holder}`);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
program.command("renew")
|
|
82
|
+
.argument("<lease-id>")
|
|
83
|
+
.requiredOption("--holder <holder>")
|
|
84
|
+
.option("--ttl-minutes <minutes>", "New lifetime from now", parsePositiveInteger)
|
|
85
|
+
.option("--json")
|
|
86
|
+
.action((leaseId, options) => {
|
|
87
|
+
const lease = renewDeploymentLease({ leaseId, holder: options.holder, ttlMinutes: options.ttlMinutes });
|
|
88
|
+
if (options.json)
|
|
89
|
+
printJson(lease);
|
|
90
|
+
else
|
|
91
|
+
console.log(`${lease.id}\tready\texpires=${lease.expiresAt}`);
|
|
92
|
+
});
|
|
93
|
+
program.command("release")
|
|
94
|
+
.argument("<lease-id>")
|
|
95
|
+
.option("--holder <holder>")
|
|
96
|
+
.option("--force", "Operator release without holder match")
|
|
97
|
+
.option("--json")
|
|
98
|
+
.action(async (leaseId, options) => {
|
|
99
|
+
if (!options.force && !options.holder)
|
|
100
|
+
throw new Error("--holder is required unless --force is used");
|
|
101
|
+
const lease = await releaseDeploymentLease({ leaseId, holder: options.holder, force: options.force });
|
|
102
|
+
if (options.json)
|
|
103
|
+
printJson(lease);
|
|
104
|
+
else
|
|
105
|
+
console.log(`${lease.id}\t${lease.status}`);
|
|
106
|
+
});
|
|
107
|
+
program.command("reap")
|
|
108
|
+
.option("--dry-run", "Preview cleanup", true)
|
|
109
|
+
.option("--apply", "Apply cleanup")
|
|
110
|
+
.option("--json")
|
|
111
|
+
.action(async (options) => {
|
|
112
|
+
const plan = await planDeploymentPoolReap();
|
|
113
|
+
const result = options.apply ? await applyDeploymentPoolReapPlan(plan) : { applied: false, plan };
|
|
114
|
+
if (options.json)
|
|
115
|
+
printJson(result);
|
|
116
|
+
else {
|
|
117
|
+
console.log(`Deployment pool reap ${options.apply ? "apply" : "dry-run"}: ${plan.summary.selectedLeases} lease(s), ${plan.summary.selectedOrphanContainers} orphan container(s), ${plan.summary.selectedDirtySlots} dirty slot(s), ${plan.summary.selectedFailureSnapshots} failure snapshot(s), ${plan.summary.selectedArtifacts} artifact(s)`);
|
|
118
|
+
for (const item of plan.items)
|
|
119
|
+
console.log(`${item.action}\t${item.lease.id}\t${item.reasons.join("+") || "active"}`);
|
|
120
|
+
if (!options.apply)
|
|
121
|
+
console.log("Dry-run only. Apply with: pibo compute pool reap --apply");
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
program.command("doctor")
|
|
125
|
+
.option("--json")
|
|
126
|
+
.action(async (options) => {
|
|
127
|
+
const result = await getDeploymentPoolDoctor();
|
|
128
|
+
if (options.json)
|
|
129
|
+
printJson(result);
|
|
130
|
+
else {
|
|
131
|
+
console.log(`configured\t${result.configured}`);
|
|
132
|
+
console.log(`runtime-image\t${result.runtimeImageAvailable ? "ok" : "missing"}\t${result.runtimeImage}`);
|
|
133
|
+
console.log(`seed-source\t${result.seedSourceHomeAvailable ? "ok" : "missing"}\t${result.seedSourceHome}`);
|
|
134
|
+
console.log("Next: pibo compute pool status --json");
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
program.command("artifacts")
|
|
138
|
+
.option("--json")
|
|
139
|
+
.action(async (options) => {
|
|
140
|
+
const rows = await listDeploymentArtifacts();
|
|
141
|
+
if (options.json)
|
|
142
|
+
printJson({ artifacts: rows });
|
|
143
|
+
else if (!rows.length)
|
|
144
|
+
console.log("No deployment pool artifacts.");
|
|
145
|
+
else
|
|
146
|
+
for (const row of rows)
|
|
147
|
+
console.log(`${row.sha256}\t${row.bytes}\t${row.modifiedAt}\t${row.path}`);
|
|
148
|
+
});
|
|
149
|
+
program.command("seed")
|
|
150
|
+
.action(() => console.log(`Deployment seed modes:
|
|
151
|
+
full Nearly complete Pibo home plus configured workspace; excludes active runtime, lock, browser, debug, and pool state.
|
|
152
|
+
medium Operational config, selected product databases, projects, contexts, agents, and user skills; excludes heavy payload/tool/browser/debug state.
|
|
153
|
+
fresh Operational config, Google/Machine auth configuration, model defaults, contexts, and user skills; no existing product databases.
|
|
154
|
+
`));
|
|
155
|
+
await program.parseAsync(argv);
|
|
156
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { getPiboHome, piboHomePath } from "../../core/pibo-home.js";
|
|
3
|
+
export function resolveDeploymentPoolConfig(options = {}) {
|
|
4
|
+
const env = options.env ?? process.env;
|
|
5
|
+
const root = resolve(options.root ?? env.PIBO_COMPUTE_POOL_ROOT ?? piboHomePath("compute-pool"));
|
|
6
|
+
const baseURL = parseBaseURL(options.baseURL ?? env.PIBO_COMPUTE_POOL_BASE_URL);
|
|
7
|
+
const slotCount = positiveInteger(env.PIBO_COMPUTE_POOL_SLOT_COUNT, 10);
|
|
8
|
+
const maxActive = Math.min(slotCount, positiveInteger(env.PIBO_COMPUTE_POOL_MAX_ACTIVE, 3));
|
|
9
|
+
const portBase = validPort(env.PIBO_COMPUTE_POOL_PORT_BASE, 5000);
|
|
10
|
+
const portStride = positiveInteger(env.PIBO_COMPUTE_POOL_PORT_STRIDE, 10);
|
|
11
|
+
if (portBase + (slotCount - 1) * portStride + 1 > 65535) {
|
|
12
|
+
throw new Error("Deployment pool slot ports exceed 65535");
|
|
13
|
+
}
|
|
14
|
+
return {
|
|
15
|
+
root,
|
|
16
|
+
databasePath: resolve(root, "pool.sqlite"),
|
|
17
|
+
artifactRoot: resolve(root, "artifacts"),
|
|
18
|
+
slotsRoot: resolve(root, "slots"),
|
|
19
|
+
failuresRoot: resolve(root, "failures"),
|
|
20
|
+
baseURL,
|
|
21
|
+
slotCount,
|
|
22
|
+
maxActive,
|
|
23
|
+
portBase,
|
|
24
|
+
portStride,
|
|
25
|
+
defaultTtlMinutes: positiveInteger(env.PIBO_COMPUTE_POOL_TTL_MINUTES, 60),
|
|
26
|
+
failedRetentionMinutes: positiveInteger(env.PIBO_COMPUTE_POOL_FAILED_RETENTION_MINUTES, 120),
|
|
27
|
+
maxFailedSnapshots: positiveInteger(env.PIBO_COMPUTE_POOL_MAX_FAILED_SNAPSHOTS, 3),
|
|
28
|
+
artifactRetentionHours: positiveInteger(env.PIBO_COMPUTE_POOL_ARTIFACT_RETENTION_HOURS, 24),
|
|
29
|
+
maxArtifacts: positiveInteger(env.PIBO_COMPUTE_POOL_MAX_ARTIFACTS, 10),
|
|
30
|
+
minMemoryAvailableMb: positiveInteger(env.PIBO_COMPUTE_POOL_MIN_MEMORY_AVAILABLE_MB, 1536),
|
|
31
|
+
minDiskAvailableGb: positiveInteger(env.PIBO_COMPUTE_POOL_MIN_DISK_AVAILABLE_GB, 10),
|
|
32
|
+
runtimeImage: nonEmpty(env.PIBO_COMPUTE_POOL_RUNTIME_IMAGE, "pibo:latest"),
|
|
33
|
+
envFile: optionalResolved(env.PIBO_COMPUTE_POOL_ENV_FILE),
|
|
34
|
+
seedSourceHome: resolve(env.PIBO_COMPUTE_POOL_SEED_SOURCE_HOME ?? getPiboHome()),
|
|
35
|
+
seedSourcePiHome: optionalResolved(env.PIBO_COMPUTE_POOL_SEED_SOURCE_PI_HOME),
|
|
36
|
+
seedSourceWorkspace: optionalResolved(env.PIBO_COMPUTE_POOL_SEED_SOURCE_WORKSPACE),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
export function requireDeploymentPoolBaseURL(config) {
|
|
40
|
+
if (!config.baseURL) {
|
|
41
|
+
throw new Error("PIBO_COMPUTE_POOL_BASE_URL is required for deployment acquire");
|
|
42
|
+
}
|
|
43
|
+
return config.baseURL;
|
|
44
|
+
}
|
|
45
|
+
export function deploymentSlotDefinitions(config) {
|
|
46
|
+
return Array.from({ length: config.slotCount }, (_, index) => {
|
|
47
|
+
const ordinal = index + 1;
|
|
48
|
+
const id = `slot-${String(ordinal).padStart(2, "0")}`;
|
|
49
|
+
const webPort = config.portBase + index * config.portStride;
|
|
50
|
+
const gatewayPort = webPort + 1;
|
|
51
|
+
return {
|
|
52
|
+
id,
|
|
53
|
+
ordinal,
|
|
54
|
+
webPort,
|
|
55
|
+
gatewayPort,
|
|
56
|
+
publicUrl: config.baseURL ? deploymentSlotURL(id, config.baseURL).toString() : undefined,
|
|
57
|
+
};
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
export function deploymentSlotURL(slotId, baseURL) {
|
|
61
|
+
if (!/^slot-\d{2}$/.test(slotId))
|
|
62
|
+
throw new Error(`Invalid deployment slot id "${slotId}"`);
|
|
63
|
+
const url = new URL(baseURL.toString());
|
|
64
|
+
url.hostname = `${slotId}.${baseURL.hostname}`;
|
|
65
|
+
return url;
|
|
66
|
+
}
|
|
67
|
+
function parseBaseURL(value) {
|
|
68
|
+
if (!value?.trim())
|
|
69
|
+
return undefined;
|
|
70
|
+
let url;
|
|
71
|
+
try {
|
|
72
|
+
url = new URL(value);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
throw new Error("PIBO_COMPUTE_POOL_BASE_URL must be an absolute HTTP or HTTPS URL");
|
|
76
|
+
}
|
|
77
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.pathname !== "/" || url.search || url.hash) {
|
|
78
|
+
throw new Error("PIBO_COMPUTE_POOL_BASE_URL must contain only scheme, hostname, and optional port");
|
|
79
|
+
}
|
|
80
|
+
return url;
|
|
81
|
+
}
|
|
82
|
+
function positiveInteger(value, fallback) {
|
|
83
|
+
if (!value?.trim())
|
|
84
|
+
return fallback;
|
|
85
|
+
const parsed = Number(value);
|
|
86
|
+
if (!Number.isInteger(parsed) || parsed < 1)
|
|
87
|
+
throw new Error(`Expected positive integer, received "${value}"`);
|
|
88
|
+
return parsed;
|
|
89
|
+
}
|
|
90
|
+
function validPort(value, fallback) {
|
|
91
|
+
const port = positiveInteger(value, fallback);
|
|
92
|
+
if (port > 65535)
|
|
93
|
+
throw new Error(`Invalid port ${port}`);
|
|
94
|
+
return port;
|
|
95
|
+
}
|
|
96
|
+
function nonEmpty(value, fallback) {
|
|
97
|
+
return value?.trim() || fallback;
|
|
98
|
+
}
|
|
99
|
+
function optionalResolved(value) {
|
|
100
|
+
return value?.trim() ? resolve(value) : undefined;
|
|
101
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { promisify } from "node:util";
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
export const DEPLOYMENT_POOL_LABEL = "pibo.deploymentPool";
|
|
6
|
+
export const DEPLOYMENT_LEASE_LABEL = "pibo.deployment.leaseId";
|
|
7
|
+
export const DEPLOYMENT_SLOT_LABEL = "pibo.deployment.slotId";
|
|
8
|
+
export const DEPLOYMENT_HOLDER_LABEL = "pibo.deployment.holder";
|
|
9
|
+
export const DEPLOYMENT_EXPIRES_LABEL = "pibo.deployment.expiresAt";
|
|
10
|
+
export const DEPLOYMENT_ARTIFACT_LABEL = "pibo.deployment.artifactSha256";
|
|
11
|
+
export const DEPLOYMENT_SEED_LABEL = "pibo.deployment.seedMode";
|
|
12
|
+
export function buildDeploymentContainerArgs(input) {
|
|
13
|
+
const command = [
|
|
14
|
+
"set -eu",
|
|
15
|
+
"export DISPLAY=:99",
|
|
16
|
+
"if command -v Xvfb >/dev/null 2>&1 && ! pgrep -x Xvfb >/dev/null 2>&1; then Xvfb :99 -screen 0 1920x1080x24 -ac -nolisten tcp >/tmp/xvfb.log 2>&1 & fi",
|
|
17
|
+
"exec node /opt/pibo-runtime/node_modules/@pasko70/pibo/dist/bin/pibo.js gateway:web --web-host 0.0.0.0 --web-port 4788 --gateway-port 4789",
|
|
18
|
+
].join("; ");
|
|
19
|
+
return [
|
|
20
|
+
"run",
|
|
21
|
+
"-d",
|
|
22
|
+
"--name",
|
|
23
|
+
input.lease.containerName,
|
|
24
|
+
"--hostname",
|
|
25
|
+
input.slot.id,
|
|
26
|
+
"--memory",
|
|
27
|
+
"1536m",
|
|
28
|
+
"--memory-swap",
|
|
29
|
+
"1536m",
|
|
30
|
+
"--cpus",
|
|
31
|
+
"1.0",
|
|
32
|
+
"--pids-limit",
|
|
33
|
+
"512",
|
|
34
|
+
"--shm-size",
|
|
35
|
+
"512m",
|
|
36
|
+
"--init",
|
|
37
|
+
"--restart",
|
|
38
|
+
"no",
|
|
39
|
+
"--log-driver",
|
|
40
|
+
"json-file",
|
|
41
|
+
"--log-opt",
|
|
42
|
+
"max-size=10m",
|
|
43
|
+
"--log-opt",
|
|
44
|
+
"max-file=3",
|
|
45
|
+
"-e",
|
|
46
|
+
"NODE_ENV=production",
|
|
47
|
+
"-e",
|
|
48
|
+
"PIBO_HOME=/root/.pibo",
|
|
49
|
+
"-e",
|
|
50
|
+
"HOME=/root",
|
|
51
|
+
"-e",
|
|
52
|
+
"PIBO_COMPUTE_POOL=1",
|
|
53
|
+
"-e",
|
|
54
|
+
`PIBO_COMPUTE_POOL_LEASE_ID=${input.lease.id}`,
|
|
55
|
+
...(input.config.envFile && existsSync(input.config.envFile) ? ["--env-file", input.config.envFile] : []),
|
|
56
|
+
"-p",
|
|
57
|
+
`127.0.0.1:${input.slot.webPort}:4788`,
|
|
58
|
+
"-p",
|
|
59
|
+
`127.0.0.1:${input.slot.gatewayPort}:4789`,
|
|
60
|
+
"-v",
|
|
61
|
+
`${input.lease.artifactRuntimePath}:/opt/pibo-runtime:ro`,
|
|
62
|
+
"-v",
|
|
63
|
+
`${input.homePath}:/root/.pibo`,
|
|
64
|
+
"-v",
|
|
65
|
+
`${input.piHomePath}:/root/.pi`,
|
|
66
|
+
"-v",
|
|
67
|
+
`${input.workspacePath}:/workspace`,
|
|
68
|
+
"-w",
|
|
69
|
+
"/workspace",
|
|
70
|
+
"--label",
|
|
71
|
+
`${DEPLOYMENT_POOL_LABEL}=true`,
|
|
72
|
+
"--label",
|
|
73
|
+
`${DEPLOYMENT_LEASE_LABEL}=${input.lease.id}`,
|
|
74
|
+
"--label",
|
|
75
|
+
`${DEPLOYMENT_SLOT_LABEL}=${input.slot.id}`,
|
|
76
|
+
"--label",
|
|
77
|
+
`${DEPLOYMENT_HOLDER_LABEL}=${safeLabelValue(input.lease.holder)}`,
|
|
78
|
+
"--label",
|
|
79
|
+
`${DEPLOYMENT_EXPIRES_LABEL}=${input.lease.expiresAt}`,
|
|
80
|
+
"--label",
|
|
81
|
+
`${DEPLOYMENT_ARTIFACT_LABEL}=${input.lease.artifactSha256}`,
|
|
82
|
+
"--label",
|
|
83
|
+
`${DEPLOYMENT_SEED_LABEL}=${input.lease.seedMode}`,
|
|
84
|
+
"--entrypoint",
|
|
85
|
+
"/bin/sh",
|
|
86
|
+
input.config.runtimeImage,
|
|
87
|
+
"-lc",
|
|
88
|
+
command,
|
|
89
|
+
];
|
|
90
|
+
}
|
|
91
|
+
export async function startDeploymentContainer(input) {
|
|
92
|
+
await execFileAsync("docker", buildDeploymentContainerArgs(input), { maxBuffer: 10 * 1024 * 1024 });
|
|
93
|
+
}
|
|
94
|
+
export async function removeDeploymentContainer(containerName) {
|
|
95
|
+
try {
|
|
96
|
+
await execFileAsync("docker", ["stop", "-t", "10", containerName]);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// Container may already be stopped or missing.
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
await execFileAsync("docker", ["rm", "-f", containerName]);
|
|
103
|
+
}
|
|
104
|
+
catch (error) {
|
|
105
|
+
if (!String(error?.stderr ?? error?.message ?? error).includes("No such container"))
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
export async function deploymentContainerExists(containerName) {
|
|
110
|
+
try {
|
|
111
|
+
await execFileAsync("docker", ["inspect", "--type=container", containerName]);
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
export async function listDeploymentContainers() {
|
|
119
|
+
const { stdout } = await execFileAsync("docker", [
|
|
120
|
+
"ps", "--all", "--filter", `label=${DEPLOYMENT_POOL_LABEL}=true`,
|
|
121
|
+
"--format", "{{.ID}}\t{{.Names}}\t{{.State}}\t{{.Label \"pibo.deployment.leaseId\"}}\t{{.Label \"pibo.deployment.slotId\"}}",
|
|
122
|
+
]);
|
|
123
|
+
return stdout.trim().split("\n").filter(Boolean).map((line) => {
|
|
124
|
+
const [id, name, state, leaseId, slotId] = line.split("\t");
|
|
125
|
+
return { id: id, name: name, state: state ?? "unknown", leaseId: leaseId || undefined, slotId: slotId || undefined };
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
export async function waitForDeploymentHealth(webPort, options = {}) {
|
|
129
|
+
const deadline = Date.now() + (options.timeoutMs ?? 90_000);
|
|
130
|
+
let lastError = "not ready";
|
|
131
|
+
while (Date.now() < deadline) {
|
|
132
|
+
try {
|
|
133
|
+
const response = await fetch(`http://127.0.0.1:${webPort}/health`, { signal: AbortSignal.timeout(2_000) });
|
|
134
|
+
if (response.ok)
|
|
135
|
+
return;
|
|
136
|
+
lastError = `HTTP ${response.status}`;
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
140
|
+
}
|
|
141
|
+
await new Promise((resolve) => setTimeout(resolve, options.intervalMs ?? 500));
|
|
142
|
+
}
|
|
143
|
+
throw new Error(`Deployment slot health check timed out on port ${webPort}: ${lastError}`);
|
|
144
|
+
}
|
|
145
|
+
export async function dockerImageExists(image) {
|
|
146
|
+
try {
|
|
147
|
+
await execFileAsync("docker", ["inspect", "--type=image", image]);
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function safeLabelValue(value) {
|
|
155
|
+
const safe = value.replace(/[^A-Za-z0-9._:@-]/g, "-").slice(0, 128);
|
|
156
|
+
return safe || "unknown";
|
|
157
|
+
}
|