@ours.network/install 1.2.1-nightly.4 → 1.2.1-nightly.41
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/GATEWAY_SETUP.md +178 -0
- package/README.md +471 -9
- package/ROOTLESS_PODMAN_VALIDATION.md +210 -0
- package/assets/Dockerfile +14 -12
- package/assets/Dockerfile.gateway +5 -0
- package/assets/docker-compose.yaml +8 -1
- package/assets/fleet-settings.example.json +48 -0
- package/assets/gateway-entrypoint.sh +37 -0
- package/assets/release-lock.json +1177 -4508
- package/assets/release.json +37 -19
- package/assets/scripts/build/build-common.mjs +2 -1
- package/assets/scripts/build/build-sdk.mjs +6 -1
- package/assets/scripts/maintenance/build-context.mjs +1 -1
- package/assets/scripts/maintenance/daemon-owner.mjs +15 -0
- package/assets/scripts/maintenance/docker-layout-conversion.mjs +7 -3
- package/assets/scripts/maintenance/recover-notifications.mjs +26 -0
- package/assets/scripts/maintenance/release-graph.mjs +18 -1
- package/assets/scripts/maintenance/state-operation.mjs +62 -5
- package/assets/scripts/runtime/README.md +7 -0
- package/assets/scripts/runtime/client-setup.mjs +80 -6
- package/assets/scripts/runtime/dependency-ready.mjs +13 -0
- package/assets/scripts/runtime/diagnostics.mjs +72 -0
- package/assets/scripts/runtime/entrypoint.sh +5 -1
- package/assets/scripts/runtime/health-messenger.mjs +7 -6
- package/assets/scripts/runtime/notifications-gateway.mjs +90 -0
- package/assets/scripts/runtime/recover.mjs +165 -0
- package/assets/scripts/runtime/start-cowork.sh +5 -0
- package/assets/scripts/runtime/start-messenger.sh +29 -4
- package/assets/scripts/runtime/start-telegram.sh +5 -0
- package/assets/scripts/runtime/supervise.mjs +35 -0
- package/assets/sources.json +54 -28
- package/install.mjs +7 -2
- package/lib/boot-readiness.mjs +43 -0
- package/lib/build-transition.mjs +16 -0
- package/lib/client-cli.mjs +127 -0
- package/lib/container-engine.mjs +127 -0
- package/lib/diagnostics.mjs +1 -0
- package/lib/docker-conversion-runtime.mjs +3 -2
- package/lib/docker-layout-installation.mjs +2 -1
- package/lib/docker-recovery.mjs +55 -0
- package/lib/docker-runtime-repair.mjs +160 -0
- package/lib/effects.mjs +357 -133
- package/lib/gateway-transition.mjs +87 -0
- package/lib/gateway.mjs +158 -0
- package/lib/legacy-migration.mjs +13 -2
- package/lib/orchestrate-uninstall.mjs +18 -6
- package/lib/orchestrate.mjs +193 -43
- package/lib/plan.mjs +27 -11
- package/lib/podman-preflight.mjs +40 -0
- package/lib/server-onboarding.mjs +164 -16
- package/lib/server-ports.mjs +17 -0
- package/lib/setup-options.mjs +99 -28
- package/lib/setup.mjs +35 -17
- package/lib/target.mjs +24 -8
- package/lib/uninstall.mjs +2 -2
- package/lib/usage.mjs +21 -7
- package/lib/workspace-setup.mjs +83 -0
- package/package.json +4 -2
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { lstatSync, readFileSync, writeFileSync, realpathSync, unlinkSync, chmodSync, symlinkSync, renameSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { join, resolve, isAbsolute, dirname } from 'node:path';
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
import { buildManagedCli } from './managed-cli.mjs';
|
|
5
|
+
|
|
6
|
+
import { createRequire } from 'node:module';
|
|
7
|
+
import { releaseBinding, verifyReleaseGraph } from '../assets/scripts/maintenance/release-graph.mjs';
|
|
8
|
+
|
|
9
|
+
const marker = '// ours-managed-cli-v1 ';
|
|
10
|
+
const hash = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
11
|
+
function stat(path) {
|
|
12
|
+
try { return lstatSync(path); } catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
13
|
+
}
|
|
14
|
+
function privateAcquisition(home, root) {
|
|
15
|
+
for (const path of [join(home, '.ours-client-install'), root]) {
|
|
16
|
+
const value = lstatSync(path);
|
|
17
|
+
if (!value.isDirectory() || value.uid !== process.getuid() || (value.mode & 0o077) || realpathSync(path) !== path) throw new Error('Unsafe private CLI directory');
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** Publish the native client, retaining a verified migration launcher for rollback. */
|
|
21
|
+
export async function publishClientCli(effects, packagePath, { policy = {}, isolated = false } = {}) {
|
|
22
|
+
const prefix = (await effects.run('npm', ['prefix', '--global'])).stdout.trim();
|
|
23
|
+
if (!isAbsolute(prefix) || resolve(prefix) !== prefix) throw new Error('npm global prefix must be an absolute normalized path');
|
|
24
|
+
const entry = join(prefix, 'bin', 'ours');
|
|
25
|
+
const before = stat(entry);
|
|
26
|
+
let previous;
|
|
27
|
+
if (before) {
|
|
28
|
+
if (before.uid !== process.getuid()) throw new Error('Refusing to replace a CLI owned by another user');
|
|
29
|
+
if (before.isSymbolicLink()) {
|
|
30
|
+
const resolved = realpathSync(entry);
|
|
31
|
+
const privateBase = join(effects.home, '.ours-client-install') + '/';
|
|
32
|
+
if (resolved.startsWith(privateBase)) {
|
|
33
|
+
const packageRoot = dirname(dirname(resolved));
|
|
34
|
+
const acquisitionRoot = dirname(dirname(dirname(packageRoot)));
|
|
35
|
+
privateAcquisition(effects.home, acquisitionRoot);
|
|
36
|
+
const retained = JSON.parse(readFileSync(join(acquisitionRoot, 'sources.json')));
|
|
37
|
+
const binding = releaseBinding(retained);
|
|
38
|
+
if (binding && binding.scope !== 'host-cli') throw new Error('Unrecognized private CLI entry');
|
|
39
|
+
verifyReleaseGraph(acquisitionRoot, retained);
|
|
40
|
+
if (!binding) {
|
|
41
|
+
// Development selections publish through npm's global package entry.
|
|
42
|
+
// Require that exact retained selection and global target on retry;
|
|
43
|
+
// integrity-bound private host CLI releases keep their stricter path.
|
|
44
|
+
const npmRoot = (await effects.run('npm', ['root', '--global'])).stdout.trim();
|
|
45
|
+
const selected = retained.packages?.['@ours.network/cli'];
|
|
46
|
+
const pkg = JSON.parse(readFileSync(join(packageRoot, 'package.json')));
|
|
47
|
+
if (!isAbsolute(npmRoot) || realpathSync(join(npmRoot, '@ours.network/cli')) !== packageRoot || selected?.type !== 'npm' || selected.version !== pkg.version) throw new Error('Unrecognized private CLI entry');
|
|
48
|
+
}
|
|
49
|
+
const pkg = JSON.parse(readFileSync(join(packageRoot, 'package.json')));
|
|
50
|
+
if (pkg.name !== '@ours.network/cli' || resolve(packageRoot, pkg.bin?.ours ?? '') !== resolved) throw new Error('Unrecognized private CLI target');
|
|
51
|
+
} else {
|
|
52
|
+
const npmRoot = (await effects.run('npm', ['root', '--global'])).stdout.trim();
|
|
53
|
+
if (!isAbsolute(npmRoot)) throw new Error('Invalid npm global package root');
|
|
54
|
+
const root = realpathSync(join(npmRoot, '@ours.network/cli'));
|
|
55
|
+
const manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
|
|
56
|
+
if (manifest.name !== '@ours.network/cli' || typeof manifest.bin?.ours !== 'string' || resolved !== realpathSync(resolve(root, manifest.bin.ours))) throw new Error('Refusing to replace an unrelated ours symlink');
|
|
57
|
+
}
|
|
58
|
+
} else {
|
|
59
|
+
if (!before.isFile() || before.nlink !== 1 || (before.mode & 0o022)) throw new Error('Refusing to replace an unsafe CLI entry');
|
|
60
|
+
previous = readFileSync(entry);
|
|
61
|
+
const line = previous.toString().split('\n')[1];
|
|
62
|
+
if (!line?.startsWith(marker)) throw new Error('Refusing to replace an unknown ours executable');
|
|
63
|
+
const binding = JSON.parse(line.slice(marker.length));
|
|
64
|
+
if (binding.schema !== 1 || previous.toString() !== buildManagedCli(binding.recordPath, binding.installerPath)) throw new Error('Managed CLI launcher was modified; no replacement performed');
|
|
65
|
+
const backup = join(effects.home, '.ours-client', `previous-cli-${hash(previous)}.cjs`);
|
|
66
|
+
const backupDir = lstatSync(join(effects.home, '.ours-client'));
|
|
67
|
+
if (!backupDir.isDirectory() || backupDir.uid !== process.getuid() || (backupDir.mode & 0o7777) !== 0o700) throw new Error('Managed CLI backup requires a private owned directory');
|
|
68
|
+
const saved = stat(backup);
|
|
69
|
+
if (!saved) writeFileSync(backup, previous, { mode: 0o600, flag: 'wx' });
|
|
70
|
+
else if (!saved.isFile() || saved.nlink !== 1 || saved.uid !== process.getuid() || (saved.mode & 0o7777) !== 0o600 || saved.size !== previous.length || !readFileSync(backup).equals(previous)) throw new Error('Managed CLI backup is unsafe or differs');
|
|
71
|
+
const current = lstatSync(entry);
|
|
72
|
+
if (current.dev !== before.dev || current.ino !== before.ino || !readFileSync(entry).equals(previous)) throw new Error('CLI changed during publication');
|
|
73
|
+
if (!isolated) unlinkSync(entry);
|
|
74
|
+
effects.out(`Previous managed CLI saved at ${backup}; server maintenance remains available through ours-install server.`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (isolated) {
|
|
78
|
+
const root = dirname(dirname(dirname(packagePath)));
|
|
79
|
+
if (!realpathSync(root).startsWith(join(effects.home, '.ours-client-install') + '/') || releaseBinding(policy)?.scope !== 'host-cli') throw new Error('Invalid private CLI acquisition');
|
|
80
|
+
privateAcquisition(effects.home, root);
|
|
81
|
+
verifyReleaseGraph(root, policy);
|
|
82
|
+
const selected = JSON.parse(readFileSync(join(packagePath, 'package.json')));
|
|
83
|
+
const target = realpathSync(resolve(packagePath, selected.bin.ours));
|
|
84
|
+
if (!target.startsWith(realpathSync(packagePath) + '/')) throw new Error('CLI entry escapes selected package');
|
|
85
|
+
const sdkEntry = realpathSync(createRequire(target).resolve('@ours.network/sdk'));
|
|
86
|
+
if (!sdkEntry.startsWith(root + '/')) throw new Error('Private CLI SDK escaped verified acquisition');
|
|
87
|
+
const sdk = JSON.parse(readFileSync(join(dirname(dirname(sdkEntry)), 'package.json')));
|
|
88
|
+
if (sdk.name !== '@ours.network/sdk' || sdk.version !== policy.release.packages['@ours.network/sdk'].version) throw new Error('Private CLI SDK differs from selection');
|
|
89
|
+
mkdirSync(dirname(entry), { recursive: true });
|
|
90
|
+
const binDir = lstatSync(dirname(entry));
|
|
91
|
+
if (!binDir.isDirectory() || binDir.uid !== process.getuid() || (binDir.mode & 0o022)) throw new Error(`CLI bin directory ${dirname(entry)} must be owned by you and not writable by group or others; run chmod go-w on it, or point npm's global prefix at a directory you own`);
|
|
92
|
+
const current = stat(entry);
|
|
93
|
+
if (before ? !current || current.dev !== before.dev || current.ino !== before.ino : current) throw new Error('CLI entry changed during publication');
|
|
94
|
+
const staged = entry + '.' + randomUUID();
|
|
95
|
+
try {
|
|
96
|
+
symlinkSync(target, staged);
|
|
97
|
+
renameSync(staged, entry);
|
|
98
|
+
} catch (error) {
|
|
99
|
+
if (stat(staged)) unlinkSync(staged);
|
|
100
|
+
if (previous && !stat(entry)) writeFileSync(entry, previous, { mode: before.mode & 0o777, flag: 'wx' });
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
if (realpathSync(entry) !== target) throw new Error('Private CLI entry changed during publication');
|
|
104
|
+
verifyReleaseGraph(root, policy);
|
|
105
|
+
return entry;
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
await effects.run('npm', ['install', '--global', '--install-links=false', '--offline', '--ignore-scripts', '--no-audit', '--no-fund', packagePath]);
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if (previous && !stat(entry)) {
|
|
111
|
+
writeFileSync(entry, previous, { mode: before.mode & 0o777, flag: 'wx' });
|
|
112
|
+
chmodSync(entry, before.mode & 0o777);
|
|
113
|
+
}
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
const published = stat(entry);
|
|
117
|
+
if (!published?.isSymbolicLink() || published.uid !== process.getuid()) throw new Error('CLI publication did not create the expected owned npm entry');
|
|
118
|
+
const npmRoot = (await effects.run('npm', ['root', '--global'])).stdout.trim();
|
|
119
|
+
if (!isAbsolute(npmRoot) || resolve(npmRoot) !== npmRoot) throw new Error('Invalid npm global package root');
|
|
120
|
+
const root = realpathSync(join(npmRoot, '@ours.network/cli'));
|
|
121
|
+
const manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
|
|
122
|
+
const selected = JSON.parse(readFileSync(join(packagePath, 'package.json'), 'utf8'));
|
|
123
|
+
const target = typeof manifest.bin?.ours === 'string' ? resolve(root, manifest.bin.ours) : '';
|
|
124
|
+
if (manifest.name !== '@ours.network/cli' || manifest.version !== selected.version || !target.startsWith(root + '/') || realpathSync(entry) !== realpathSync(target) || !stat(target)?.isFile()) throw new Error('Published CLI does not match the selected package');
|
|
125
|
+
if (!readFileSync(target).equals(readFileSync(resolve(packagePath, selected.bin.ours)))) throw new Error('Published CLI entry differs from the selected artifact');
|
|
126
|
+
return entry;
|
|
127
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export const engineName = record => record.containerEngine === undefined ? 'docker' : record.containerEngine;
|
|
5
|
+
const path = value => typeof value === 'string' && isAbsolute(value) && resolve(value) === value && !/[\x00-\x1f]/.test(value);
|
|
6
|
+
export function validateContainerEngine(record) {
|
|
7
|
+
const engine = engineName(record);
|
|
8
|
+
if (!['docker', 'podman'].includes(engine) || (record.mode !== 'docker' && (record.containerEngine !== undefined || record.containerBinding !== undefined))) throw new Error('Invalid container engine selection');
|
|
9
|
+
const b = record.containerBinding;
|
|
10
|
+
if (engine === 'docker') {
|
|
11
|
+
if (b !== undefined) throw new Error('Unexpected Docker backend binding');
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
if (!b || b.version !== 1 || !Number.isInteger(b.uid) || b.uid <= 0 || !path(b.graphRoot) || !path(b.runRoot)
|
|
15
|
+
|| !/^[a-f0-9]{64}$/.test(b.namespaceDigest ?? '') || !path(b.socket) || !path(b.provider) || typeof b.driver !== 'string' || !/^[a-z0-9]+$/.test(b.driver)
|
|
16
|
+
|| Object.keys(b).some(k => !['version', 'uid', 'graphRoot', 'runRoot', 'socket', 'provider', 'driver', 'namespaceDigest'].includes(k))) throw new Error('Invalid rootless Podman backend binding');
|
|
17
|
+
}
|
|
18
|
+
function podmanEnvironment(record, env, uid) {
|
|
19
|
+
validateContainerEngine(record);
|
|
20
|
+
const b = record.containerBinding;
|
|
21
|
+
if (uid !== b.uid) throw new Error('Podman installation belongs to another rootless owner');
|
|
22
|
+
for (const key of ['CONTAINER_HOST', 'CONTAINER_CONNECTION', 'CONTAINERS_STORAGE_CONF', 'CONTAINERS_CONF', 'PODMAN_USERNS', 'STORAGE_DRIVER', 'STORAGE_OPTS']) {
|
|
23
|
+
if (env[key]) throw new Error(`${key} conflicts with the retained local Podman backend`);
|
|
24
|
+
}
|
|
25
|
+
if ((env.OURS_PODMAN_SOCKET && env.OURS_PODMAN_SOCKET !== b.socket) || env.DOCKER_CONTEXT || (env.DOCKER_HOST && env.DOCKER_HOST !== `unix://${b.socket}`)
|
|
26
|
+
|| (env.PODMAN_COMPOSE_PROVIDER && env.PODMAN_COMPOSE_PROVIDER !== b.provider)) throw new Error('Environment conflicts with the retained Podman backend/provider');
|
|
27
|
+
return { DOCKER_CONTEXT: undefined, DOCKER_HOST: `unix://${b.socket}`, PODMAN_COMPOSE_PROVIDER: b.provider, PODMAN_COMPOSE_WARNING_LOGS: 'false' };
|
|
28
|
+
}
|
|
29
|
+
/** The only container command boundary. Legacy records keep their Docker path. */
|
|
30
|
+
export async function runContainer(effects, record, args, options = {}) {
|
|
31
|
+
if (engineName(record) === 'docker') return effects.run('docker', args, options);
|
|
32
|
+
const env = { ...(effects.env ?? {}), ...(options.env ?? {}) };
|
|
33
|
+
const selected = podmanEnvironment(record, env, process.getuid?.());
|
|
34
|
+
const b = record.containerBinding;
|
|
35
|
+
if (args[0] === 'build' && !args.some(arg => arg === '--format' || arg.startsWith('--format='))) args = ['build', '--format', 'docker', ...args.slice(1)];
|
|
36
|
+
// Docker reports a missing object as 1; Podman inspect uses 125. Probe exact
|
|
37
|
+
// existence separately so transport/permission failures are never absence.
|
|
38
|
+
if (['image', 'volume'].includes(args[0]) && args[1] === 'inspect' && options.allowCodes?.includes(1)) {
|
|
39
|
+
const found = await runContainer(effects, record, [args[0], 'exists', args.at(-1)], { ...options, allowCodes: [1] });
|
|
40
|
+
if (found.code === 1) return { code: 1, stdout: '', ok: false };
|
|
41
|
+
}
|
|
42
|
+
// Native commands and the Compose provider must address the SAME local store.
|
|
43
|
+
const prefix = args[0] === 'compose' ? ['--remote', '--url', `unix://${b.socket}`] : ['--remote=false', '--root', b.graphRoot, '--runroot', b.runRoot, '--storage-driver', b.driver];
|
|
44
|
+
const result = await effects.run('podman', [...prefix, ...args],
|
|
45
|
+
{ ...options, env: { ...options.env, ...selected } });
|
|
46
|
+
if (args[0] === 'image' && args[1] === 'inspect' && !args.includes('--format') && result.code === 0) {
|
|
47
|
+
const images = JSON.parse(result.stdout);
|
|
48
|
+
for (const value of images) if (/^[a-f0-9]{64}$/.test(value.Id)) value.Id = `sha256:${value.Id}`;
|
|
49
|
+
return { ...result, stdout: JSON.stringify(images) };
|
|
50
|
+
}
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
export function namespaceDigest(info) {
|
|
54
|
+
const maps = info.host?.idMappings;
|
|
55
|
+
const normalized = ['uidmap', 'gidmap'].map(key => {
|
|
56
|
+
const rows = maps?.[key];
|
|
57
|
+
if (!Array.isArray(rows) || !rows.length) throw new Error('Podman user namespace mappings are unavailable');
|
|
58
|
+
return rows.map(row => {
|
|
59
|
+
const values = [row.container_id, row.host_id, row.size];
|
|
60
|
+
if (values.some(value => !Number.isInteger(value) || value < 0) || row.size < 1) throw new Error('Invalid Podman namespace mapping');
|
|
61
|
+
return values;
|
|
62
|
+
}).sort((a, b) => a[0] - b[0]);
|
|
63
|
+
});
|
|
64
|
+
return createHash('sha256').update(JSON.stringify(normalized)).digest('hex');
|
|
65
|
+
}
|
|
66
|
+
export async function bindPodman(effects, record) {
|
|
67
|
+
if (effects.platform.platform !== 'linux' || process.getuid?.() === 0) throw new Error('Podman server installation requires a non-root Linux user');
|
|
68
|
+
const env = effects.env ?? {};
|
|
69
|
+
const provider = record.containerBinding?.provider ?? env.PODMAN_COMPOSE_PROVIDER;
|
|
70
|
+
if (!path(provider)) throw new Error('Set PODMAN_COMPOSE_PROVIDER to an absolute standalone Docker Compose executable path');
|
|
71
|
+
// Reject ambient redirection before even probing an unbound store.
|
|
72
|
+
for (const key of ['CONTAINER_HOST', 'CONTAINER_CONNECTION', 'CONTAINERS_STORAGE_CONF', 'CONTAINERS_CONF', 'PODMAN_USERNS', 'STORAGE_DRIVER', 'STORAGE_OPTS', 'DOCKER_CONTEXT']) {
|
|
73
|
+
if (env[key]) throw new Error(`${key} conflicts with local rootless Podman`);
|
|
74
|
+
}
|
|
75
|
+
const info = JSON.parse((await effects.run('podman', ['--remote=false', 'info', '--format', 'json'])).stdout);
|
|
76
|
+
if (info.host?.security?.rootless !== true) throw new Error('Podman must report rootless=true');
|
|
77
|
+
const socket = record.containerBinding?.socket ?? env.OURS_PODMAN_SOCKET ?? info.host.remoteSocket?.path;
|
|
78
|
+
const observed = { version: 1, uid: process.getuid(), graphRoot: info.store?.graphRoot, runRoot: info.store?.runRoot, driver: info.store?.graphDriverName, socket, provider, namespaceDigest: namespaceDigest(info) };
|
|
79
|
+
validateContainerEngine({ ...record, containerBinding: observed });
|
|
80
|
+
if (record.containerBinding && Object.keys(observed).some(k => observed[k] !== record.containerBinding[k])) throw new Error('Podman backend differs from retained installation');
|
|
81
|
+
podmanEnvironment({ ...record, containerBinding: observed }, env, process.getuid());
|
|
82
|
+
const api = JSON.parse((await effects.run('podman', ['--remote', '--url', `unix://${socket}`, 'info', '--format', 'json'])).stdout);
|
|
83
|
+
if (api.host?.security?.rootless !== true || api.store?.graphRoot !== observed.graphRoot || api.store?.runRoot !== observed.runRoot || api.store?.graphDriverName !== observed.driver || namespaceDigest(api) !== observed.namespaceDigest) throw new Error('Podman socket does not address the selected rootless storage; enable the user podman.socket');
|
|
84
|
+
const version = (await effects.run(provider, ['version', '--short'])).stdout.trim();
|
|
85
|
+
const match = /^v?(\d+)\.(\d+)\./.exec(version);
|
|
86
|
+
if (!match || +match[1] < 2 || (+match[1] === 2 && +match[2] < 35)) throw new Error('Standalone Docker Compose 2.35+ is required');
|
|
87
|
+
record.containerBinding = observed;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Resolve Compose rather than maintaining a second, drifting build definition. */
|
|
91
|
+
export function nativeBuildCommands(config, services, env = {}) {
|
|
92
|
+
const commands = [], seen = new Set();
|
|
93
|
+
for (const name of services) {
|
|
94
|
+
const service = config.services?.[name], build = service?.build;
|
|
95
|
+
if (!build || typeof build !== 'object' || !path(build.context) || !service.image) throw new Error(`Missing resolved build definition for ${name}`);
|
|
96
|
+
const supported = ['context', 'dockerfile', 'target', 'args', 'secrets', 'cache_from', 'cache_to', 'platforms', 'labels', 'no_cache', 'pull'];
|
|
97
|
+
if (Object.keys(build).some(k => !supported.includes(k))) throw new Error(`Unsupported Podman build option for ${name}`);
|
|
98
|
+
const args = ['build', '--format', 'docker', '--layers', '--file', resolve(build.context, build.dockerfile ?? 'Dockerfile'), '--tag', service.image];
|
|
99
|
+
if (build.target) args.push('--target', build.target);
|
|
100
|
+
const platforms = build.platforms ?? (service.platform ? [service.platform] : []);
|
|
101
|
+
if (platforms.length > 1) throw new Error('Multi-platform Podman builds are not supported');
|
|
102
|
+
if (platforms.length) args.push('--platform', platforms[0]);
|
|
103
|
+
for (const [key, value] of Object.entries(build.args ?? {})) {
|
|
104
|
+
if (value === null) throw new Error(`Unresolved build argument: ${key}`);
|
|
105
|
+
args.push('--build-arg', `${key}=${value}`);
|
|
106
|
+
}
|
|
107
|
+
for (const [key, value] of Object.entries(build.labels ?? {})) args.push('--label', `${key}=${value}`);
|
|
108
|
+
for (const key of ['cache_from', 'cache_to']) for (const value of build[key] ?? []) args.push('--' + key.replace('_', '-'), value);
|
|
109
|
+
if (build.no_cache) args.push('--no-cache');
|
|
110
|
+
if (build.pull) args.push('--pull=always');
|
|
111
|
+
for (const secret of build.secrets ?? []) {
|
|
112
|
+
const source = typeof secret === 'string' ? secret : secret.source;
|
|
113
|
+
const id = typeof secret === 'string' ? source : secret.target ?? source;
|
|
114
|
+
const definition = config.secrets?.[source];
|
|
115
|
+
if (!/^[A-Za-z0-9_-]+$/.test(id)) throw new Error('Invalid build secret ID');
|
|
116
|
+
if (definition?.environment) {
|
|
117
|
+
// Native local Podman supports type=env; values never enter argv/files.
|
|
118
|
+
if (env[definition.environment]) args.push('--secret', `id=${id},type=env,env=${definition.environment}`);
|
|
119
|
+
} else if (definition?.file && path(definition.file)) args.push('--secret', `id=${id},src=${definition.file}`);
|
|
120
|
+
else throw new Error(`Unsupported build secret: ${source}`);
|
|
121
|
+
}
|
|
122
|
+
args.push(build.context);
|
|
123
|
+
const key = JSON.stringify(args);
|
|
124
|
+
if (!seen.has(key)) { seen.add(key); commands.push(args); }
|
|
125
|
+
}
|
|
126
|
+
return commands;
|
|
127
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { redactDiagnostic, commandFailure, installerFailure } from '../assets/scripts/runtime/diagnostics.mjs';
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { runContainer } from './container-engine.mjs';
|
|
1
2
|
import * as fs from 'node:fs';
|
|
2
3
|
import { join } from 'node:path';
|
|
3
4
|
import { randomUUID } from 'node:crypto';
|
|
@@ -33,7 +34,7 @@ ENTRYPOINT ["node", "/opt/ours/docker/state-operation.mjs"]
|
|
|
33
34
|
`);
|
|
34
35
|
// Build maintenance first: both targets inherit the same retained runtime.
|
|
35
36
|
for (const target of ['maintenance', 'runtime']) {
|
|
36
|
-
await effects
|
|
37
|
+
await runContainer(effects, record, ['build', '--platform', 'linux/amd64', '--target', target,
|
|
37
38
|
'--tag', `${record.project}:${target}`, context]);
|
|
38
39
|
}
|
|
39
40
|
atomicWriteConfig(compose, fs.readFileSync(join(assets, 'docker-compose.yaml')));
|
|
@@ -82,7 +83,7 @@ export async function runDockerConversion(record, selected, operation, label, ef
|
|
|
82
83
|
const path = join(record.root, `.conversion-compose-${randomUUID()}.json`);
|
|
83
84
|
try {
|
|
84
85
|
fs.writeFileSync(path, JSON.stringify(definition), { mode: 0o600, flag: 'wx' });
|
|
85
|
-
const result = await effects
|
|
86
|
+
const result = await runContainer(effects, record, ['compose', '--file', path, '--project-name', record.project,
|
|
86
87
|
'run', '--rm', '--no-deps', '-T', 'state-operation', operation, ...(label ? [label] : [])]);
|
|
87
88
|
if (operation === 'cleanup') {
|
|
88
89
|
const summary = JSON.parse(result.stdout);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { runContainer } from './container-engine.mjs';
|
|
1
2
|
import * as fs from 'node:fs';
|
|
2
3
|
import { join, basename } from 'node:path';
|
|
3
4
|
import { randomUUID } from 'node:crypto';
|
|
@@ -46,7 +47,7 @@ export async function convertDockerInstallation(record, operation, effects) {
|
|
|
46
47
|
await effects.serverLifecycle(record, 'start', running);
|
|
47
48
|
const cleanup = await effects.runDockerConversion(record, volumes, 'cleanup');
|
|
48
49
|
if (cleanup.emptyVolumes.length) {
|
|
49
|
-
await effects
|
|
50
|
+
await runContainer(effects, record, ['volume', 'rm', ...cleanup.emptyVolumes.map(alias => volumes.sources[alias])]);
|
|
50
51
|
}
|
|
51
52
|
try {
|
|
52
53
|
const stat = fs.lstatSync(source.configPath);
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { readFileSync, lstatSync, realpathSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { atomicWriteConfig } from './config.mjs';
|
|
4
|
+
import { engineName, runContainer } from './container-engine.mjs';
|
|
5
|
+
|
|
6
|
+
const assets = new URL('../assets/', import.meta.url);
|
|
7
|
+
const files = ['scripts/runtime/recover.mjs', 'scripts/runtime/dependency-ready.mjs',
|
|
8
|
+
...['entrypoint.sh', 'start-telegram.sh', 'start-cowork.sh', 'start-messenger.sh'].map(f => `scripts/runtime/${f}`)];
|
|
9
|
+
const owned = (path, directory = false) => {
|
|
10
|
+
const s = lstatSync(path);
|
|
11
|
+
if (!(directory ? s.isDirectory() : s.isFile()) || s.uid !== process.getuid() || (!directory && s.nlink !== 1)
|
|
12
|
+
|| realpathSync(path) !== path || (s.mode & 0o7000)) throw new Error('Unsafe retained recovery delivery');
|
|
13
|
+
};
|
|
14
|
+
/** Refresh installer-owned runtime scripts without changing source selection or state. */
|
|
15
|
+
export function refreshDockerRecovery(record) {
|
|
16
|
+
for (const dir of [record.root, record.workDir, join(record.workDir, 'scripts'), join(record.workDir, 'scripts/runtime')]) owned(dir, true);
|
|
17
|
+
const marker = join(record.workDir, '.recovery-rebuild');
|
|
18
|
+
let changed = false;
|
|
19
|
+
const replace = (name, bytes) => {
|
|
20
|
+
const path = join(record.workDir, name);
|
|
21
|
+
if (existsSync(path)) { owned(path); if (readFileSync(path).equals(bytes)) return; }
|
|
22
|
+
// Persist before edits, so interruption cannot reuse an image lacking recovery.
|
|
23
|
+
atomicWriteConfig(marker, 'required\n'); changed = true;
|
|
24
|
+
atomicWriteConfig(path, bytes.toString());
|
|
25
|
+
};
|
|
26
|
+
for (const name of files) replace(name, readFileSync(new URL(name, assets)));
|
|
27
|
+
const dockerfile = join(record.workDir, 'Dockerfile'); owned(dockerfile);
|
|
28
|
+
const text = readFileSync(dockerfile, 'utf8');
|
|
29
|
+
const line = 'COPY --chmod=644 scripts/runtime/recover.mjs scripts/runtime/dependency-ready.mjs /opt/ours/docker/';
|
|
30
|
+
if (!text.includes('scripts/runtime/recover.mjs')) replace('Dockerfile', Buffer.from(text.replace('FROM node:24 AS maintenance', `${line}\nFROM node:24 AS maintenance`)));
|
|
31
|
+
return changed || existsSync(marker);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** An overlay also applies to retained Compose files, with one-shot jobs untouched. */
|
|
35
|
+
export function recoveryCompose(record) {
|
|
36
|
+
return { services: Object.fromEntries(record.services.map(name => [name, { restart: 'unless-stopped' }])) };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Change only exact project-owned long-running containers; never start/recreate. */
|
|
40
|
+
export async function reconcileDockerRecovery(record, effects) {
|
|
41
|
+
if (engineName(record) !== 'docker') return;
|
|
42
|
+
const query = await runContainer(effects, record, ['ps', '-aq', '--no-trunc', '--filter', `label=com.docker.compose.project=${record.project}`]);
|
|
43
|
+
const ids = query.stdout.split(/\s+/).filter(Boolean);
|
|
44
|
+
if (!ids.length) return;
|
|
45
|
+
const inspected = JSON.parse((await runContainer(effects, record, ['inspect', ...ids])).stdout);
|
|
46
|
+
const selected = [];
|
|
47
|
+
for (const c of inspected) {
|
|
48
|
+
const labels = c.Config?.Labels;
|
|
49
|
+
if (labels?.['com.docker.compose.project'] !== record.project) throw new Error('Recovery container project ownership differs');
|
|
50
|
+
if (!record.services.includes(labels['com.docker.compose.service']) || labels['com.docker.compose.oneoff']?.toLowerCase() !== 'false') continue;
|
|
51
|
+
if (!ids.includes(c.Id)) throw new Error('Recovery container identity differs');
|
|
52
|
+
if (c.HostConfig?.RestartPolicy?.Name !== 'unless-stopped') selected.push(c.Id);
|
|
53
|
+
}
|
|
54
|
+
if (selected.length) await runContainer(effects, record, ['update', '--restart', 'unless-stopped', ...selected]);
|
|
55
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { engineName, runContainer } from './container-engine.mjs';
|
|
2
|
+
/** Qualify cached images before reuse; repair only the known root-owned 0600 policy. */
|
|
3
|
+
import { existsSync, readdirSync, lstatSync, readFileSync, writeFileSync, mkdtempSync, rmSync, realpathSync, openSync, closeSync, fstatSync, fchmodSync, constants } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
6
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
7
|
+
import { atomicWriteConfig } from './config.mjs';
|
|
8
|
+
|
|
9
|
+
const policyPath = '/opt/ours/sources.json';
|
|
10
|
+
const digest = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
11
|
+
const fail = message => { throw new Error(`Docker runtime verification refused: ${message}`); };
|
|
12
|
+
const imageId = value => /^sha256:[0-9a-f]{64}$/.test(value ?? '');
|
|
13
|
+
|
|
14
|
+
export function refreshDockerPolicyCopy(record) {
|
|
15
|
+
let rebuildScripts = false;
|
|
16
|
+
if (record.workDir !== join(record.root, 'runtime')) fail('Unsafe retained Dockerfile: runtime directory differs from installation record');
|
|
17
|
+
for (const directory of [record.root, record.workDir]) {
|
|
18
|
+
const stat = lstatSync(directory);
|
|
19
|
+
if (!stat.isDirectory() || realpathSync(directory) !== directory) fail('Unsafe retained Dockerfile: installation directory is linked or not canonical');
|
|
20
|
+
if (stat.uid !== process.getuid()) fail('Unsafe retained Dockerfile: installation directory belongs to another user');
|
|
21
|
+
if (stat.mode & 0o7077) fail('Unsafe retained Dockerfile: installation directory must be owner-private (0700)');
|
|
22
|
+
}
|
|
23
|
+
const path = join(record.workDir, 'Dockerfile');
|
|
24
|
+
let fd;
|
|
25
|
+
try { fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
|
|
26
|
+
catch (error) { fail(`Unsafe retained Dockerfile: cannot open a regular unlinked file (${error.code})`); }
|
|
27
|
+
try {
|
|
28
|
+
const stat = fstatSync(fd);
|
|
29
|
+
if (!stat.isFile()) fail('Unsafe retained Dockerfile: expected a regular file');
|
|
30
|
+
if (stat.nlink !== 1) fail('Unsafe retained Dockerfile: hard links are not supported');
|
|
31
|
+
if (stat.uid !== process.getuid()) fail(`Unsafe retained Dockerfile: file owner ${stat.uid} differs from current user ${process.getuid()}`);
|
|
32
|
+
if (stat.mode & 0o7000) fail('Unsafe retained Dockerfile: special permission bits are not supported');
|
|
33
|
+
// Asset copies can retain 0664 under umask 002. Private canonical ancestors
|
|
34
|
+
// exclude other users; normalize this owned inode without following links.
|
|
35
|
+
fchmodSync(fd, 0o600);
|
|
36
|
+
const text = readFileSync(fd, 'utf8');
|
|
37
|
+
const current = lstatSync(path);
|
|
38
|
+
if (current.dev !== stat.dev || current.ino !== stat.ino || current.nlink !== 1) fail('Unsafe retained Dockerfile: file changed during normalization');
|
|
39
|
+
const oldLine = /^COPY sources\.json \/opt\/ours\/sources\.json$/m;
|
|
40
|
+
let updated = text.replace(oldLine, 'COPY --chmod=644 sources.json /opt/ours/sources.json');
|
|
41
|
+
// Only the shipped script COPY instructions are normalized. Existing image
|
|
42
|
+
// repair remains narrow; a private build context needs an actual rebuild.
|
|
43
|
+
const beforeScripts = updated;
|
|
44
|
+
const shipped = readFileSync(new URL('../assets/Dockerfile', import.meta.url), 'utf8');
|
|
45
|
+
const scriptCopies = new Map(shipped.split('\n').filter(line => line.startsWith('COPY --chmod=644 scripts/')).map(line => [line.replace(' --chmod=644', ''), line]));
|
|
46
|
+
updated = updated.split('\n').map(line => scriptCopies.get(line) || line).join('\n');
|
|
47
|
+
if (updated !== beforeScripts) {
|
|
48
|
+
for (const directory of ['scripts/maintenance', 'scripts/runtime']) {
|
|
49
|
+
const base = join(record.workDir, directory);
|
|
50
|
+
const dir = lstatSync(base);
|
|
51
|
+
if (!dir.isDirectory() || dir.uid !== process.getuid() || realpathSync(base) !== base) fail('Unsafe retained scripts directory');
|
|
52
|
+
for (const name of readdirSync(base)) {
|
|
53
|
+
if (!/\.(?:mjs|sh)$/.test(name)) continue;
|
|
54
|
+
const file = join(base, name), st = lstatSync(file);
|
|
55
|
+
if (!st.isFile() || st.nlink !== 1 || st.uid !== process.getuid() || realpathSync(file) !== file) fail('Unsafe retained script file');
|
|
56
|
+
if (!(st.mode & 0o004)) rebuildScripts = true;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// Store the need across an interrupted build. It is cleared only after the
|
|
61
|
+
// rebuilt image passes the same nonroot release/runtime qualification.
|
|
62
|
+
const marker = join(record.workDir, '.script-permissions-rebuild');
|
|
63
|
+
if (rebuildScripts) atomicWriteConfig(marker, 'required\n');
|
|
64
|
+
if (updated !== text) atomicWriteConfig(path, updated);
|
|
65
|
+
return rebuildScripts || existsSync(marker);
|
|
66
|
+
} finally { closeSync(fd); }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const probeScript = `
|
|
70
|
+
const fs = require('node:fs'), crypto = require('node:crypto');
|
|
71
|
+
(async () => {
|
|
72
|
+
const path = '${policyPath}', s = fs.lstatSync(path);
|
|
73
|
+
if (!s.isFile() || s.nlink !== 1 || s.uid !== 0 || s.gid !== 0 || ![0o600, 0o644].includes(s.mode & 0o7777)) throw new Error('Unsupported policy ownership/type/mode');
|
|
74
|
+
let bytes;
|
|
75
|
+
try { bytes = fs.readFileSync(path); }
|
|
76
|
+
catch (error) { if (process.getuid() !== 0 && error.code === 'EACCES') { process.exitCode = 74; return; } throw error; }
|
|
77
|
+
const graph = await import('/opt/ours/maintenance/release-graph.mjs');
|
|
78
|
+
graph.verifyRuntimeRelease('/opt/ours');
|
|
79
|
+
const hash = bytes => crypto.createHash('sha256').update(bytes).digest('hex');
|
|
80
|
+
const records = {};
|
|
81
|
+
for (const name of ['package.json', 'package-lock.json', 'dependency-tree.json', 'build-context.json']) {
|
|
82
|
+
const file = '/opt/ours/' + name, stat = fs.lstatSync(file);
|
|
83
|
+
if (!stat.isFile() || stat.nlink !== 1) throw new Error('Unsupported build record');
|
|
84
|
+
records[name] = hash(fs.readFileSync(file));
|
|
85
|
+
}
|
|
86
|
+
console.log(JSON.stringify({ uid: s.uid, gid: s.gid, regular: true, links: s.nlink, mode: s.mode & 0o7777, policyHash: hash(bytes), records }));
|
|
87
|
+
})().catch(error => { console.error(error.message); process.exitCode = 1; });
|
|
88
|
+
`;
|
|
89
|
+
|
|
90
|
+
export async function qualifyDockerRuntime(record, effects) {
|
|
91
|
+
if (!/^ours-[a-z0-9-]+$/.test(record.project ?? '')) fail('unexpected installation project');
|
|
92
|
+
const uid = record.uid ?? 1000, gid = record.gid ?? 1000;
|
|
93
|
+
if (![uid, gid].every(n => Number.isInteger(n) && n > 0)) fail('non-root runtime UID/GID required');
|
|
94
|
+
const tag = `${record.project}:runtime`;
|
|
95
|
+
const inspect = async target => {
|
|
96
|
+
const { stdout } = await runContainer(effects, record, ['image', 'inspect', target], { timeout: 60000 });
|
|
97
|
+
const [value] = JSON.parse(stdout);
|
|
98
|
+
if (!imageId(value?.Id) || !Array.isArray(value.RootFS?.Layers) || value.Config?.Labels?.['network.ours.build-context'] !== '1') fail('image lacks recognized build metadata');
|
|
99
|
+
return value;
|
|
100
|
+
};
|
|
101
|
+
const probe = async (id, user) => {
|
|
102
|
+
const name = `ours-runtime-probe-${randomUUID()}`;
|
|
103
|
+
try {
|
|
104
|
+
const result = await runContainer(effects, record, ['run', '--rm', '--name', name, '--read-only', '--network', 'none', '--cap-drop', 'ALL',
|
|
105
|
+
'--security-opt', 'no-new-privileges:true', '--pids-limit', '64', '--memory', '256m', '--user', user, '--entrypoint', 'node', id, '-e', probeScript],
|
|
106
|
+
{ allowCodes: [1, 74], timeout: 60000 });
|
|
107
|
+
return { code: result.code, report: result.code === 0 ? JSON.parse(result.stdout) : null };
|
|
108
|
+
} catch (error) {
|
|
109
|
+
// A killed Docker client can leave its own bounded probe container behind.
|
|
110
|
+
try { await runContainer(effects, record, ['rm', '-f', name], { allowCodes: [1], timeout: 10000 }); } catch {}
|
|
111
|
+
throw error;
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
const policy = readFileSync(record.sourcesPath), expectedHash = digest(policy);
|
|
115
|
+
const check = (report, mode, stage) => {
|
|
116
|
+
const expected = { mode, uid: 0, gid: 0, regular: true, links: 1, policyHash: expectedHash };
|
|
117
|
+
const mismatched = Object.keys(expected).filter(key => report?.[key] !== expected[key]);
|
|
118
|
+
if (mismatched.length) {
|
|
119
|
+
// Only fixed field names, a bounded numeric mode and a comparison boolean
|
|
120
|
+
// enter diagnostics. Never disclose policy content, hashes or probe output.
|
|
121
|
+
const observedMode = Number.isInteger(report?.mode) && report.mode >= 0 && report.mode <= 0o7777
|
|
122
|
+
? report.mode.toString(8).padStart(4, '0') : 'invalid';
|
|
123
|
+
fail(`image policy verification failed (stage=${stage}; engine=${engineName(record)}; mismatched=${mismatched.join(',')}; mode=${observedMode}; expectedMode=${mode.toString(8).padStart(4, '0')}; policyMatch=${report?.policyHash === expectedHash})`);
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
const original = await inspect(tag);
|
|
127
|
+
const ordinary = await probe(original.Id, `${uid}:${gid}`);
|
|
128
|
+
if (ordinary.code === 0) { check(ordinary.report, 0o644, 'ordinary'); return; }
|
|
129
|
+
if (ordinary.code !== 74) fail('cached image is unusable; automatic repair only supports the known source-policy permission defect');
|
|
130
|
+
const privileged = await probe(original.Id, '0:0');
|
|
131
|
+
if (privileged.code !== 0) fail('original release graph could not be verified');
|
|
132
|
+
check(privileged.report, 0o600, 'privileged');
|
|
133
|
+
effects.out?.('Repairing cached Docker image permissions; keeping the selected packages and stored data.');
|
|
134
|
+
const directory = mkdtempSync(join(record.root, '.image-permissions-'));
|
|
135
|
+
const name = `ours-permission-repair-${randomUUID()}`, baseTag = `${name}:base`, candidateTag = `${name}:candidate`;
|
|
136
|
+
try {
|
|
137
|
+
// Unique local alias transports the immutable ID into BuildKit. The resulting
|
|
138
|
+
// layer ancestry and complete execution config are checked before publication.
|
|
139
|
+
await runContainer(effects, record, ['image', 'tag', original.Id, baseTag]);
|
|
140
|
+
if ((await inspect(baseTag)).Id !== original.Id) fail('repair base image changed');
|
|
141
|
+
writeFileSync(join(directory, 'sources.json'), policy, { mode: 0o600, flag: 'wx' });
|
|
142
|
+
writeFileSync(join(directory, 'Dockerfile'), `FROM ${baseTag}\nCOPY --chmod=644 sources.json ${policyPath}\n`, { mode: 0o600, flag: 'wx' });
|
|
143
|
+
await runContainer(effects, record, ['build', '--network=none', '--pull=false', '--tag', candidateTag, directory], { stream: true, timeout: 120000 });
|
|
144
|
+
const candidate = await inspect(candidateTag);
|
|
145
|
+
if (!isDeepStrictEqual(candidate.Config, original.Config) || !isDeepStrictEqual(candidate.RootFS.Layers.slice(0, original.RootFS.Layers.length), original.RootFS.Layers)
|
|
146
|
+
|| candidate.RootFS.Layers.length !== original.RootFS.Layers.length + 1) fail('repair changed image execution settings or ancestry');
|
|
147
|
+
const verified = await probe(candidate.Id, `${uid}:${gid}`);
|
|
148
|
+
if (verified.code !== 0) fail('repaired image did not pass runtime verification');
|
|
149
|
+
check(verified.report, 0o644, 'repaired');
|
|
150
|
+
if (!isDeepStrictEqual(verified.report.records, privileged.report.records)) fail('repair changed build provenance');
|
|
151
|
+
if ((await inspect(tag)).Id !== original.Id) fail('selected image changed during repair; retry setup');
|
|
152
|
+
await runContainer(effects, record, ['image', 'tag', candidate.Id, tag]);
|
|
153
|
+
effects.out?.('Cached Docker image repaired and verified; continuing setup.');
|
|
154
|
+
} finally {
|
|
155
|
+
for (const temporary of [candidateTag, baseTag]) {
|
|
156
|
+
try { await runContainer(effects, record, ['image', 'rm', temporary], { allowCodes: [1], timeout: 10000 }); } catch {}
|
|
157
|
+
}
|
|
158
|
+
rmSync(directory, { recursive: true, force: true });
|
|
159
|
+
}
|
|
160
|
+
}
|