@ours.network/install 1.2.0-nightly.2 → 1.2.1-nightly.10
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 +151 -131
- package/assets/Dockerfile +6 -3
- package/assets/docker-compose.yaml +21 -1
- package/assets/release-lock.json +7161 -0
- package/assets/release.json +57 -0
- package/assets/scripts/build/build-common.mjs +1 -1
- package/assets/scripts/build/build-sdk.mjs +6 -1
- package/assets/scripts/build/record-build.mjs +2 -0
- 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 +3 -1
- package/assets/scripts/maintenance/release-graph.mjs +111 -0
- package/assets/scripts/maintenance/state-operation.mjs +4 -2
- package/assets/scripts/runtime/client-setup.mjs +3 -3
- package/assets/scripts/runtime/entrypoint.sh +1 -1
- package/assets/scripts/runtime/legacy-import.mjs +103 -0
- package/assets/scripts/runtime/runtime-common.mjs +2 -0
- package/assets/sources.json +96 -16
- package/install.mjs +2 -2
- package/install.sh +1 -1
- package/lib/build-transition.mjs +13 -7
- package/lib/client-cli.mjs +117 -0
- package/lib/docker-conversion-runtime.mjs +1 -0
- package/lib/docker-runtime-repair.mjs +125 -0
- package/lib/effects.mjs +163 -85
- package/lib/fleet-settings.mjs +43 -0
- package/lib/legacy-migration.mjs +202 -0
- package/lib/legacy-state.mjs +205 -0
- package/lib/managed-cli.mjs +164 -0
- package/lib/orchestrate.mjs +162 -58
- package/lib/plan.mjs +13 -7
- package/lib/prompt.mjs +113 -119
- package/lib/server-onboarding.mjs +114 -0
- package/lib/setup-options.mjs +253 -0
- package/lib/setup.mjs +154 -0
- package/lib/target.mjs +4 -4
- package/lib/uninstall.mjs +2 -2
- package/lib/usage.mjs +49 -44
- package/package.json +5 -4
package/lib/build-transition.mjs
CHANGED
|
@@ -2,10 +2,16 @@ import { readFileSync } from 'node:fs';
|
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { validateInstallation } from './plan.mjs';
|
|
5
|
+
import { info, ok, warn } from './ui.mjs';
|
|
5
6
|
|
|
6
7
|
/** The caller holds the installation lock; installation.json owns retry state. */
|
|
7
8
|
export async function serverBuildTransition(record, args, effects) {
|
|
8
9
|
validateInstallation(record, record.root);
|
|
10
|
+
const stage = async (label, action) => {
|
|
11
|
+
effects.out?.(info(label));
|
|
12
|
+
try { const result = await action(); effects.out?.(ok(`${label} complete`)); return result; }
|
|
13
|
+
catch (error) { effects.out?.(warn(`Server ${args.operation} stopped during ${label.toLowerCase()}.`)); throw error; }
|
|
14
|
+
};
|
|
9
15
|
const path = join(record.root, 'installation.json');
|
|
10
16
|
const save = transition => {
|
|
11
17
|
record = { ...record, buildTransition: transition };
|
|
@@ -14,9 +20,9 @@ export async function serverBuildTransition(record, args, effects) {
|
|
|
14
20
|
};
|
|
15
21
|
let transition = record.buildTransition;
|
|
16
22
|
if (!transition) {
|
|
17
|
-
const candidate = await effects.prepareServerBuild(record, args);
|
|
23
|
+
const candidate = await stage('Prepare the updated runtime', () => effects.prepareServerBuild(record, args));
|
|
18
24
|
try {
|
|
19
|
-
await effects.checkServerBuild(record, candidate, !!args.compatible, args.operation);
|
|
25
|
+
await stage('Verify package and stored-state compatibility', () => effects.checkServerBuild(record, candidate, !!args.compatible, args.operation));
|
|
20
26
|
transition = { operation: args.operation, candidate, compatible: !!args.compatible,
|
|
21
27
|
...(args.sources ? { sourcePolicyHash: createHash('sha256').update(readFileSync(args.sources)).digest('hex') } : {}),
|
|
22
28
|
runningServices: await effects.serverLifecycle(record, 'status'), phase: 'prepared' };
|
|
@@ -34,19 +40,19 @@ export async function serverBuildTransition(record, args, effects) {
|
|
|
34
40
|
const { candidate, runningServices } = transition;
|
|
35
41
|
// A failed readiness check can leave some new services running. Every retry
|
|
36
42
|
// excludes those writers again and retains the original requested running set.
|
|
37
|
-
await effects.retireServerBuildRuntime(record);
|
|
43
|
+
await stage('Stop services before updating stored state', () => effects.retireServerBuildRuntime(record));
|
|
38
44
|
if (transition.phase === 'prepared') {
|
|
39
|
-
await effects.updateServerBuildState(record, candidate, transition.compatible, transition.operation);
|
|
45
|
+
await stage('Update stored state while retaining identities and credentials', () => effects.updateServerBuildState(record, candidate, transition.compatible, transition.operation));
|
|
40
46
|
transition = { ...transition, phase: 'state-updated' };
|
|
41
47
|
save(transition);
|
|
42
48
|
}
|
|
43
49
|
if (transition.phase === 'state-updated') {
|
|
44
|
-
await effects.publishServerBuild(record, candidate);
|
|
50
|
+
await stage('Activate the prepared runtime', () => effects.publishServerBuild(record, candidate));
|
|
45
51
|
transition = { ...transition, phase: 'runtime-activated' };
|
|
46
52
|
save(transition);
|
|
47
53
|
}
|
|
48
|
-
await effects.validateServerBuildState(record);
|
|
49
|
-
await effects.serverLifecycle(record, 'start', runningServices);
|
|
54
|
+
await stage('Verify retained state and identities', () => effects.validateServerBuildState(record));
|
|
55
|
+
await stage('Restore previously running services and check readiness', () => effects.serverLifecycle(record, 'start', runningServices));
|
|
50
56
|
const completed = { ...record, sourcePolicyHash: candidate.sourcePolicyHash };
|
|
51
57
|
delete completed.buildTransition;
|
|
52
58
|
effects.writeJson(path, JSON.stringify(completed, null, 2) + '\n');
|
|
@@ -0,0 +1,117 @@
|
|
|
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
|
+
if (releaseBinding(retained)?.scope !== 'host-cli') throw new Error('Unrecognized private CLI entry');
|
|
38
|
+
verifyReleaseGraph(acquisitionRoot, retained);
|
|
39
|
+
const pkg = JSON.parse(readFileSync(join(packageRoot, 'package.json')));
|
|
40
|
+
if (pkg.name !== '@ours.network/cli' || resolve(packageRoot, pkg.bin?.ours ?? '') !== resolved) throw new Error('Unrecognized private CLI target');
|
|
41
|
+
} else {
|
|
42
|
+
const npmRoot = (await effects.run('npm', ['root', '--global'])).stdout.trim();
|
|
43
|
+
if (!isAbsolute(npmRoot)) throw new Error('Invalid npm global package root');
|
|
44
|
+
const root = realpathSync(join(npmRoot, '@ours.network/cli'));
|
|
45
|
+
const manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
|
|
46
|
+
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');
|
|
47
|
+
}
|
|
48
|
+
} else {
|
|
49
|
+
if (!before.isFile() || before.nlink !== 1 || (before.mode & 0o022)) throw new Error('Refusing to replace an unsafe CLI entry');
|
|
50
|
+
previous = readFileSync(entry);
|
|
51
|
+
const line = previous.toString().split('\n')[1];
|
|
52
|
+
if (!line?.startsWith(marker)) throw new Error('Refusing to replace an unknown ours executable');
|
|
53
|
+
const binding = JSON.parse(line.slice(marker.length));
|
|
54
|
+
if (binding.schema !== 1 || previous.toString() !== buildManagedCli(binding.recordPath, binding.installerPath)) throw new Error('Managed CLI launcher was modified; no replacement performed');
|
|
55
|
+
const backup = join(effects.home, '.ours-client', `previous-cli-${hash(previous)}.cjs`);
|
|
56
|
+
const backupDir = lstatSync(join(effects.home, '.ours-client'));
|
|
57
|
+
if (!backupDir.isDirectory() || backupDir.uid !== process.getuid() || (backupDir.mode & 0o7777) !== 0o700) throw new Error('Managed CLI backup requires a private owned directory');
|
|
58
|
+
const saved = stat(backup);
|
|
59
|
+
if (!saved) writeFileSync(backup, previous, { mode: 0o600, flag: 'wx' });
|
|
60
|
+
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');
|
|
61
|
+
const current = lstatSync(entry);
|
|
62
|
+
if (current.dev !== before.dev || current.ino !== before.ino || !readFileSync(entry).equals(previous)) throw new Error('CLI changed during publication');
|
|
63
|
+
if (!isolated) unlinkSync(entry);
|
|
64
|
+
effects.out(`Previous managed CLI saved at ${backup}; server maintenance remains available through ours-install server.`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (isolated) {
|
|
68
|
+
const root = dirname(dirname(dirname(packagePath)));
|
|
69
|
+
if (!realpathSync(root).startsWith(join(effects.home, '.ours-client-install') + '/') || releaseBinding(policy)?.scope !== 'host-cli') throw new Error('Invalid private CLI acquisition');
|
|
70
|
+
privateAcquisition(effects.home, root);
|
|
71
|
+
verifyReleaseGraph(root, policy);
|
|
72
|
+
const selected = JSON.parse(readFileSync(join(packagePath, 'package.json')));
|
|
73
|
+
const target = realpathSync(resolve(packagePath, selected.bin.ours));
|
|
74
|
+
if (!target.startsWith(realpathSync(packagePath) + '/')) throw new Error('CLI entry escapes selected package');
|
|
75
|
+
const sdkEntry = realpathSync(createRequire(target).resolve('@ours.network/sdk'));
|
|
76
|
+
if (!sdkEntry.startsWith(root + '/')) throw new Error('Private CLI SDK escaped verified acquisition');
|
|
77
|
+
const sdk = JSON.parse(readFileSync(join(dirname(dirname(sdkEntry)), 'package.json')));
|
|
78
|
+
if (sdk.name !== '@ours.network/sdk' || sdk.version !== policy.release.packages['@ours.network/sdk'].version) throw new Error('Private CLI SDK differs from selection');
|
|
79
|
+
mkdirSync(dirname(entry), { recursive: true });
|
|
80
|
+
const binDir = lstatSync(dirname(entry));
|
|
81
|
+
if (!binDir.isDirectory() || binDir.uid !== process.getuid() || (binDir.mode & 0o022)) throw new Error('CLI bin directory must be owned and not writable by others');
|
|
82
|
+
const current = stat(entry);
|
|
83
|
+
if (before ? !current || current.dev !== before.dev || current.ino !== before.ino : current) throw new Error('CLI entry changed during publication');
|
|
84
|
+
const staged = entry + '.' + randomUUID();
|
|
85
|
+
try {
|
|
86
|
+
symlinkSync(target, staged);
|
|
87
|
+
renameSync(staged, entry);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
if (stat(staged)) unlinkSync(staged);
|
|
90
|
+
if (previous && !stat(entry)) writeFileSync(entry, previous, { mode: before.mode & 0o777, flag: 'wx' });
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
if (realpathSync(entry) !== target) throw new Error('Private CLI entry changed during publication');
|
|
94
|
+
verifyReleaseGraph(root, policy);
|
|
95
|
+
return entry;
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
await effects.run('npm', ['install', '--global', '--install-links=false', '--offline', '--ignore-scripts', '--no-audit', '--no-fund', packagePath]);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
if (previous && !stat(entry)) {
|
|
101
|
+
writeFileSync(entry, previous, { mode: before.mode & 0o777, flag: 'wx' });
|
|
102
|
+
chmodSync(entry, before.mode & 0o777);
|
|
103
|
+
}
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
const published = stat(entry);
|
|
107
|
+
if (!published?.isSymbolicLink() || published.uid !== process.getuid()) throw new Error('CLI publication did not create the expected owned npm entry');
|
|
108
|
+
const npmRoot = (await effects.run('npm', ['root', '--global'])).stdout.trim();
|
|
109
|
+
if (!isAbsolute(npmRoot) || resolve(npmRoot) !== npmRoot) throw new Error('Invalid npm global package root');
|
|
110
|
+
const root = realpathSync(join(npmRoot, '@ours.network/cli'));
|
|
111
|
+
const manifest = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
|
|
112
|
+
const selected = JSON.parse(readFileSync(join(packagePath, 'package.json'), 'utf8'));
|
|
113
|
+
const target = typeof manifest.bin?.ours === 'string' ? resolve(root, manifest.bin.ours) : '';
|
|
114
|
+
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');
|
|
115
|
+
if (!readFileSync(target).equals(readFileSync(resolve(packagePath, selected.bin.ours)))) throw new Error('Published CLI entry differs from the selected artifact');
|
|
116
|
+
return entry;
|
|
117
|
+
}
|
|
@@ -22,6 +22,7 @@ export async function prepareDockerConversionRuntime(record, effects, assets) {
|
|
|
22
22
|
fs.writeFileSync(join(context, 'Dockerfile'), `# syntax=docker/dockerfile:1
|
|
23
23
|
FROM ${record.project}:runtime AS runtime
|
|
24
24
|
COPY --chmod=644 runtime/ /opt/ours/docker/
|
|
25
|
+
COPY --chmod=644 maintenance/release-graph.mjs /opt/ours/maintenance/release-graph.mjs
|
|
25
26
|
FROM runtime AS maintenance
|
|
26
27
|
USER 0:0
|
|
27
28
|
COPY --chmod=644 maintenance/ /opt/ours/docker/
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/** Qualify cached images before reuse; repair only the known root-owned 0600 policy. */
|
|
2
|
+
import { lstatSync, readFileSync, writeFileSync, mkdtempSync, rmSync, realpathSync, openSync, closeSync, fstatSync, fchmodSync, constants } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
5
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
6
|
+
import { atomicWriteConfig } from './config.mjs';
|
|
7
|
+
|
|
8
|
+
const policyPath = '/opt/ours/sources.json';
|
|
9
|
+
const digest = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
10
|
+
const fail = message => { throw new Error(`Docker runtime verification refused: ${message}`); };
|
|
11
|
+
const imageId = value => /^sha256:[0-9a-f]{64}$/.test(value ?? '');
|
|
12
|
+
|
|
13
|
+
export function refreshDockerPolicyCopy(record) {
|
|
14
|
+
if (record.workDir !== join(record.root, 'runtime')) fail('Unsafe retained Dockerfile: runtime directory differs from installation record');
|
|
15
|
+
for (const directory of [record.root, record.workDir]) {
|
|
16
|
+
const stat = lstatSync(directory);
|
|
17
|
+
if (!stat.isDirectory() || realpathSync(directory) !== directory) fail('Unsafe retained Dockerfile: installation directory is linked or not canonical');
|
|
18
|
+
if (stat.uid !== process.getuid()) fail('Unsafe retained Dockerfile: installation directory belongs to another user');
|
|
19
|
+
if (stat.mode & 0o7077) fail('Unsafe retained Dockerfile: installation directory must be owner-private (0700)');
|
|
20
|
+
}
|
|
21
|
+
const path = join(record.workDir, 'Dockerfile');
|
|
22
|
+
let fd;
|
|
23
|
+
try { fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
|
|
24
|
+
catch (error) { fail(`Unsafe retained Dockerfile: cannot open a regular unlinked file (${error.code})`); }
|
|
25
|
+
try {
|
|
26
|
+
const stat = fstatSync(fd);
|
|
27
|
+
if (!stat.isFile()) fail('Unsafe retained Dockerfile: expected a regular file');
|
|
28
|
+
if (stat.nlink !== 1) fail('Unsafe retained Dockerfile: hard links are not supported');
|
|
29
|
+
if (stat.uid !== process.getuid()) fail(`Unsafe retained Dockerfile: file owner ${stat.uid} differs from current user ${process.getuid()}`);
|
|
30
|
+
if (stat.mode & 0o7000) fail('Unsafe retained Dockerfile: special permission bits are not supported');
|
|
31
|
+
// Asset copies can retain 0664 under umask 002. Private canonical ancestors
|
|
32
|
+
// exclude other users; normalize this owned inode without following links.
|
|
33
|
+
fchmodSync(fd, 0o600);
|
|
34
|
+
const text = readFileSync(fd, 'utf8');
|
|
35
|
+
const current = lstatSync(path);
|
|
36
|
+
if (current.dev !== stat.dev || current.ino !== stat.ino || current.nlink !== 1) fail('Unsafe retained Dockerfile: file changed during normalization');
|
|
37
|
+
const oldLine = /^COPY sources\.json \/opt\/ours\/sources\.json$/m;
|
|
38
|
+
if (oldLine.test(text)) atomicWriteConfig(path, text.replace(oldLine, 'COPY --chmod=644 sources.json /opt/ours/sources.json'));
|
|
39
|
+
} finally { closeSync(fd); }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const probeScript = `
|
|
43
|
+
const fs = require('node:fs'), crypto = require('node:crypto');
|
|
44
|
+
(async () => {
|
|
45
|
+
const path = '${policyPath}', s = fs.lstatSync(path);
|
|
46
|
+
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');
|
|
47
|
+
let bytes;
|
|
48
|
+
try { bytes = fs.readFileSync(path); }
|
|
49
|
+
catch (error) { if (process.getuid() !== 0 && error.code === 'EACCES') { process.exitCode = 74; return; } throw error; }
|
|
50
|
+
const graph = await import('/opt/ours/maintenance/release-graph.mjs');
|
|
51
|
+
graph.verifyRuntimeRelease('/opt/ours');
|
|
52
|
+
const hash = bytes => crypto.createHash('sha256').update(bytes).digest('hex');
|
|
53
|
+
const records = {};
|
|
54
|
+
for (const name of ['package.json', 'package-lock.json', 'dependency-tree.json', 'build-context.json']) {
|
|
55
|
+
const file = '/opt/ours/' + name, stat = fs.lstatSync(file);
|
|
56
|
+
if (!stat.isFile() || stat.nlink !== 1) throw new Error('Unsupported build record');
|
|
57
|
+
records[name] = hash(fs.readFileSync(file));
|
|
58
|
+
}
|
|
59
|
+
console.log(JSON.stringify({ uid: s.uid, gid: s.gid, regular: true, links: s.nlink, mode: s.mode & 0o7777, policyHash: hash(bytes), records }));
|
|
60
|
+
})().catch(error => { console.error(error.message); process.exitCode = 1; });
|
|
61
|
+
`;
|
|
62
|
+
|
|
63
|
+
export async function qualifyDockerRuntime(record, effects) {
|
|
64
|
+
if (!/^ours-[a-z0-9-]+$/.test(record.project ?? '')) fail('unexpected installation project');
|
|
65
|
+
const uid = record.uid ?? 1000, gid = record.gid ?? 1000;
|
|
66
|
+
if (![uid, gid].every(n => Number.isInteger(n) && n > 0)) fail('non-root runtime UID/GID required');
|
|
67
|
+
const tag = `${record.project}:runtime`;
|
|
68
|
+
const inspect = async target => {
|
|
69
|
+
const { stdout } = await effects.run('docker', ['image', 'inspect', target], { timeout: 60000 });
|
|
70
|
+
const [value] = JSON.parse(stdout);
|
|
71
|
+
if (!imageId(value?.Id) || !Array.isArray(value.RootFS?.Layers) || value.Config?.Labels?.['network.ours.build-context'] !== '1') fail('image lacks recognized build metadata');
|
|
72
|
+
return value;
|
|
73
|
+
};
|
|
74
|
+
const probe = async (id, user) => {
|
|
75
|
+
const name = `ours-runtime-probe-${randomUUID()}`;
|
|
76
|
+
try {
|
|
77
|
+
const result = await effects.run('docker', ['run', '--rm', '--name', name, '--read-only', '--network', 'none', '--cap-drop', 'ALL',
|
|
78
|
+
'--security-opt', 'no-new-privileges:true', '--pids-limit', '64', '--memory', '256m', '--user', user, '--entrypoint', 'node', id, '-e', probeScript],
|
|
79
|
+
{ allowCodes: [1, 74], timeout: 60000 });
|
|
80
|
+
return { code: result.code, report: result.code === 0 ? JSON.parse(result.stdout) : null };
|
|
81
|
+
} catch (error) {
|
|
82
|
+
// A killed Docker client can leave its own bounded probe container behind.
|
|
83
|
+
try { await effects.run('docker', ['rm', '-f', name], { allowCodes: [1], timeout: 10000 }); } catch {}
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
const policy = readFileSync(record.sourcesPath), expectedHash = digest(policy);
|
|
88
|
+
const check = (report, mode) => {
|
|
89
|
+
if (!report || report.mode !== mode || report.uid !== 0 || report.gid !== 0 || !report.regular || report.links !== 1 || report.policyHash !== expectedHash) fail('image policy differs from retained selection or expected permissions');
|
|
90
|
+
};
|
|
91
|
+
const original = await inspect(tag);
|
|
92
|
+
const ordinary = await probe(original.Id, `${uid}:${gid}`);
|
|
93
|
+
if (ordinary.code === 0) { check(ordinary.report, 0o644); return; }
|
|
94
|
+
if (ordinary.code !== 74) fail('cached image is unusable; automatic repair only supports the known source-policy permission defect');
|
|
95
|
+
const privileged = await probe(original.Id, '0:0');
|
|
96
|
+
if (privileged.code !== 0) fail('original release graph could not be verified');
|
|
97
|
+
check(privileged.report, 0o600);
|
|
98
|
+
effects.out?.('Repairing cached Docker image permissions; keeping the selected packages and stored data.');
|
|
99
|
+
const directory = mkdtempSync(join(record.root, '.image-permissions-'));
|
|
100
|
+
const name = `ours-permission-repair-${randomUUID()}`, baseTag = `${name}:base`, candidateTag = `${name}:candidate`;
|
|
101
|
+
try {
|
|
102
|
+
// Unique local alias transports the immutable ID into BuildKit. The resulting
|
|
103
|
+
// layer ancestry and complete execution config are checked before publication.
|
|
104
|
+
await effects.run('docker', ['image', 'tag', original.Id, baseTag]);
|
|
105
|
+
if ((await inspect(baseTag)).Id !== original.Id) fail('repair base image changed');
|
|
106
|
+
writeFileSync(join(directory, 'sources.json'), policy, { mode: 0o600, flag: 'wx' });
|
|
107
|
+
writeFileSync(join(directory, 'Dockerfile'), `FROM ${baseTag}\nCOPY --chmod=644 sources.json ${policyPath}\n`, { mode: 0o600, flag: 'wx' });
|
|
108
|
+
await effects.run('docker', ['build', '--network=none', '--pull=false', '--tag', candidateTag, directory], { stream: true, timeout: 120000 });
|
|
109
|
+
const candidate = await inspect(candidateTag);
|
|
110
|
+
if (!isDeepStrictEqual(candidate.Config, original.Config) || !isDeepStrictEqual(candidate.RootFS.Layers.slice(0, original.RootFS.Layers.length), original.RootFS.Layers)
|
|
111
|
+
|| candidate.RootFS.Layers.length !== original.RootFS.Layers.length + 1) fail('repair changed image execution settings or ancestry');
|
|
112
|
+
const verified = await probe(candidate.Id, `${uid}:${gid}`);
|
|
113
|
+
if (verified.code !== 0) fail('repaired image did not pass runtime verification');
|
|
114
|
+
check(verified.report, 0o644);
|
|
115
|
+
if (!isDeepStrictEqual(verified.report.records, privileged.report.records)) fail('repair changed build provenance');
|
|
116
|
+
if ((await inspect(tag)).Id !== original.Id) fail('selected image changed during repair; retry setup');
|
|
117
|
+
await effects.run('docker', ['image', 'tag', candidate.Id, tag]);
|
|
118
|
+
effects.out?.('Cached Docker image repaired and verified; continuing setup.');
|
|
119
|
+
} finally {
|
|
120
|
+
for (const temporary of [candidateTag, baseTag]) {
|
|
121
|
+
try { await effects.run('docker', ['image', 'rm', temporary], { allowCodes: [1], timeout: 10000 }); } catch {}
|
|
122
|
+
}
|
|
123
|
+
rmSync(directory, { recursive: true, force: true });
|
|
124
|
+
}
|
|
125
|
+
}
|