mirror of
https://github.com/PR0M3TH3AN/TORCH.git
synced 2026-10-31 18:01:55 +00:00
Bound self-host candidates to clean commits
This commit is contained in:
1 parent
6374b87f73
commit
c535edf72c
5 files changed
+116
-14
No files matched your search
@@ -322,7 +322,9 @@ deployment provider, so configuration alone cannot trigger an external action.
|
||||
|
||||
TORCH source checkouts can stage a self-host candidate without changing the
|
||||
active version. Candidate build runs the complete local acceptance, lint, and
|
||||
syntax gates before writing its receipt:
|
||||
syntax gates before writing its receipt. Git checkout staging is bound to a
|
||||
clean exact commit, copies tracked source plus its local dependency tree,
|
||||
excludes unrelated untracked files, and refuses escaping symbolic links:
|
||||
|
||||
```bash
|
||||
node bin/torch.mjs candidate plan --source . --json
|
||||
|
||||
@@ -1336,6 +1336,13 @@ Candidate releases MUST run side by side with stable using separate state,
|
||||
socket, lock, port, and fixture namespaces. Candidate tests MUST not control
|
||||
the live development fleet.
|
||||
|
||||
For a Git source checkout, candidate staging MUST bind the artifact to the
|
||||
exact clean tracked commit. It may include the installed dependency tree needed
|
||||
to execute acceptance, but MUST exclude arbitrary untracked files and reject
|
||||
modified tracked input. Artifact-local relative dependency links are permitted;
|
||||
links that escape the candidate root are refused. Non-Git inputs are treated as
|
||||
already-prepared release artifacts and every contained entry is inventoried.
|
||||
|
||||
Candidate acceptance includes:
|
||||
|
||||
- initialize and analyze a fixture repository;
|
||||
|
||||
@@ -121,7 +121,7 @@ test_integrity_note:
|
||||
- id: SCN-candidate-isolation
|
||||
given: an inactive candidate in a namespace separate from project and stable runtime state
|
||||
when: acceptance is incomplete, complete, or the accepted files are later modified
|
||||
then: incomplete staging is removed, successful staging stays inactive, read-only status writes nothing, and stale validation blocks activation
|
||||
then: incomplete staging is removed, successful staging stays inactive, read-only status writes nothing, stale validation blocks activation, Git staging binds to a clean exact commit, local dependencies remain runnable, and unrelated untracked files never enter the artifact
|
||||
- id: SCN-atomic-upgrade-rollback
|
||||
given: accepted forward-and-rollback-compatible versions installed side by side
|
||||
when: activation is interrupted after the atomic pointer swap and the service restarts
|
||||
|
||||
+72
-11
@@ -1,9 +1,10 @@
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import {
|
||||
cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync,
|
||||
renameSync, rmSync, symlinkSync, writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { basename, dirname, join, relative, resolve, sep } from 'node:path';
|
||||
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
||||
import { TorchError } from '../kernel/errors.mjs';
|
||||
import { torchDataHome } from '../kernel/paths.mjs';
|
||||
|
||||
@@ -55,22 +56,33 @@ function inside(parent, child) {
|
||||
return resolve(child).startsWith(root);
|
||||
}
|
||||
|
||||
function inventory(root, { excludeValidation = false } = {}) {
|
||||
function artifactPath(root, path) {
|
||||
return relative(root, path).split(sep).join('/');
|
||||
}
|
||||
|
||||
function inventory(root, { excludeValidation = false, include = () => true } = {}) {
|
||||
const records = [];
|
||||
const visit = (directory) => {
|
||||
for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
if (entry.name === '.git') continue;
|
||||
const path = join(directory, entry.name);
|
||||
const name = relative(root, path);
|
||||
const name = artifactPath(root, path);
|
||||
if (excludeValidation && name === '.torch-validation.json') continue;
|
||||
if (!include(name, entry)) continue;
|
||||
const stat = lstatSync(path);
|
||||
if (stat.isSymbolicLink()) {
|
||||
throw new TorchError(`Candidate artifacts may not contain symbolic links: ${name}`, {
|
||||
code: 'CANDIDATE_SYMLINK_REFUSED', details: { path: name },
|
||||
});
|
||||
const link = readlinkSync(path);
|
||||
const target = resolve(dirname(path), link);
|
||||
if (isAbsolute(link) || !inside(root, target) || !existsSync(target)) {
|
||||
throw new TorchError(`Candidate symbolic link must resolve inside the artifact: ${name}`, {
|
||||
code: 'CANDIDATE_SYMLINK_REFUSED', details: { path: name, target: link },
|
||||
});
|
||||
}
|
||||
records.push({ path: name, bytes: Buffer.byteLength(link), type: 'symlink', link });
|
||||
}
|
||||
if (stat.isDirectory()) visit(path);
|
||||
else if (stat.isFile()) records.push({ path: name, bytes: stat.size });
|
||||
else if (stat.isFile()) records.push({ path: name, bytes: stat.size, type: 'file' });
|
||||
else if (stat.isSymbolicLink()) { /* recorded above */ }
|
||||
else throw new TorchError(`Unsupported candidate artifact entry: ${name}`, {
|
||||
code: 'CANDIDATE_ENTRY_REFUSED', details: { path: name },
|
||||
});
|
||||
@@ -83,11 +95,43 @@ function inventory(root, { excludeValidation = false } = {}) {
|
||||
function treeHash(root, records) {
|
||||
const digest = createHash('sha256');
|
||||
for (const record of records) {
|
||||
digest.update(record.path).update('\0').update(readFileSync(join(root, record.path))).update('\0');
|
||||
digest.update(record.path).update('\0').update(record.type).update('\0');
|
||||
digest.update(record.type === 'symlink' ? record.link : readFileSync(join(root, record.path))).update('\0');
|
||||
}
|
||||
return digest.digest('hex');
|
||||
}
|
||||
|
||||
function gitSourceDescriptor(root) {
|
||||
let topLevel;
|
||||
try {
|
||||
topLevel = execFileSync('git', ['-C', root, 'rev-parse', '--show-toplevel'], {
|
||||
encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
||||
}).trim();
|
||||
} catch { return null; }
|
||||
if (resolve(topLevel) !== resolve(root)) return null;
|
||||
const dirty = execFileSync('git', ['-C', root, 'status', '--porcelain=v1', '--untracked-files=no'], {
|
||||
encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
if (dirty) throw new TorchError('Candidate source has modified tracked files', {
|
||||
code: 'CANDIDATE_SOURCE_DIRTY', details: dirty.split('\n'),
|
||||
});
|
||||
const commit = execFileSync('git', ['-C', root, 'rev-parse', 'HEAD'], {
|
||||
encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
const tracked = new Set(execFileSync('git', ['-C', root, 'ls-files', '-z'], {
|
||||
encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
|
||||
}).split('\0').filter(Boolean));
|
||||
const include = (name, entry) => name === 'node_modules'
|
||||
|| name.startsWith('node_modules/')
|
||||
|| tracked.has(name)
|
||||
|| (entry.isDirectory() && [...tracked].some((path) => path.startsWith(`${name}/`)));
|
||||
return { mode: 'git-commit', commit, include };
|
||||
}
|
||||
|
||||
function sourceDescriptor(root) {
|
||||
return gitSourceDescriptor(root) ?? { mode: 'artifact', commit: null, include: () => true };
|
||||
}
|
||||
|
||||
function validateReleaseMetadata(root, requestedVersion) {
|
||||
const packagePath = join(root, 'package.json');
|
||||
const releasePath = join(root, 'torch-release.json');
|
||||
@@ -152,7 +196,8 @@ export class VersionService {
|
||||
});
|
||||
}
|
||||
const metadata = validateReleaseMetadata(sourceRoot, version);
|
||||
const records = inventory(sourceRoot);
|
||||
const descriptor = sourceDescriptor(sourceRoot);
|
||||
const records = inventory(sourceRoot, { include: descriptor.include });
|
||||
if (records.some((record) => record.path === '.torch-validation.json')) {
|
||||
throw new TorchError('Candidate source contains TORCH-owned validation state', {
|
||||
code: 'CANDIDATE_RESERVED_FILE', details: { path: '.torch-validation.json' },
|
||||
@@ -163,6 +208,7 @@ export class VersionService {
|
||||
action: 'candidate-install', source: sourceRoot, destination, version: metadata.version,
|
||||
digest: treeHash(sourceRoot, records), files: records.length,
|
||||
bytes: records.reduce((total, record) => total + record.bytes, 0),
|
||||
sourceMode: descriptor.mode, sourceCommit: descriptor.commit,
|
||||
compatibility: metadata.release.state,
|
||||
conflicts: existsSync(destination) ? [{ code: 'VERSION_ALREADY_INSTALLED', version: metadata.version }] : [],
|
||||
mutationPerformed: false,
|
||||
@@ -184,9 +230,23 @@ export class VersionService {
|
||||
mkdirSync(this.versionsRoot, { recursive: true });
|
||||
const staging = join(this.versionsRoot, `.candidate-${plan.version}-${randomUUID()}`);
|
||||
try {
|
||||
const descriptor = sourceDescriptor(plan.source);
|
||||
if (descriptor.mode !== plan.sourceMode || descriptor.commit !== plan.sourceCommit) {
|
||||
throw new TorchError('Candidate source identity changed after planning', {
|
||||
code: 'CANDIDATE_SOURCE_CHANGED',
|
||||
details: {
|
||||
planned: { mode: plan.sourceMode, commit: plan.sourceCommit },
|
||||
actual: { mode: descriptor.mode, commit: descriptor.commit },
|
||||
},
|
||||
});
|
||||
}
|
||||
cpSync(plan.source, staging, {
|
||||
recursive: true, errorOnExist: true, force: false,
|
||||
filter: (path) => basename(path) !== '.git',
|
||||
recursive: true, errorOnExist: true, force: false, verbatimSymlinks: true,
|
||||
filter: (path) => {
|
||||
if (resolve(path) === resolve(plan.source)) return true;
|
||||
const name = artifactPath(plan.source, path);
|
||||
return basename(path) !== '.git' && descriptor.include(name, lstatSync(path));
|
||||
},
|
||||
});
|
||||
validateReleaseMetadata(staging, plan.version);
|
||||
const copiedRecords = inventory(staging);
|
||||
@@ -206,6 +266,7 @@ export class VersionService {
|
||||
atomicJson(join(staging, '.torch-validation.json'), {
|
||||
schema: 'torch.dev/candidate-validation/v1alpha1', version: plan.version,
|
||||
digest: plan.digest, validatedAt: this.now().toISOString(),
|
||||
source: { mode: plan.sourceMode, commit: plan.sourceCommit },
|
||||
scenarios: CANDIDATE_ACCEPTANCE_SCENARIOS, evidence: acceptance.evidence ?? [], passed: true,
|
||||
});
|
||||
renameSync(staging, plan.destination);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import {
|
||||
existsSync, mkdtempSync, mkdirSync, readFileSync, symlinkSync, writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
@@ -60,6 +63,35 @@ test('SCN-candidate-isolation: staging requires complete acceptance and never ch
|
||||
() => service.activate('1.0.0'),
|
||||
(error) => error.code === 'CANDIDATE_VALIDATION_STALE',
|
||||
);
|
||||
|
||||
const checkout = candidate(root, '1.1.0', 'bounded');
|
||||
mkdirSync(join(checkout, 'node_modules', 'fixture-dependency'), { recursive: true });
|
||||
mkdirSync(join(checkout, 'node_modules', '.bin'), { recursive: true });
|
||||
writeFileSync(join(checkout, 'node_modules', 'fixture-dependency', 'index.js'), 'export const dependency = true;\n');
|
||||
symlinkSync('../fixture-dependency/index.js', join(checkout, 'node_modules', '.bin', 'fixture-dependency'));
|
||||
writeFileSync(join(checkout, '.gitignore'), 'node_modules/\nlocal-secret.env\n');
|
||||
execFileSync('git', ['init', '-b', 'main', checkout]);
|
||||
execFileSync('git', ['-C', checkout, 'config', 'user.email', '[email protected]']);
|
||||
execFileSync('git', ['-C', checkout, 'config', 'user.name', 'TORCH Test']);
|
||||
execFileSync('git', ['-C', checkout, 'add', '.']);
|
||||
execFileSync('git', ['-C', checkout, 'commit', '-m', 'candidate source']);
|
||||
writeFileSync(join(checkout, 'local-secret.env'), 'DO_NOT_COPY=true\n');
|
||||
|
||||
const boundedPlan = service.planCandidate({ source: checkout });
|
||||
assert.equal(boundedPlan.sourceMode, 'git-commit');
|
||||
assert.match(boundedPlan.sourceCommit, /^[0-9a-f]{40}$/);
|
||||
const bounded = service.installCandidate({ source: checkout, validate: accepted });
|
||||
assert.equal(existsSync(join(bounded.destination, 'local-secret.env')), false);
|
||||
assert.equal(existsSync(join(bounded.destination, 'node_modules', '.bin', 'fixture-dependency')), true);
|
||||
assert.deepEqual(JSON.parse(readFileSync(bounded.validation, 'utf8')).source, {
|
||||
mode: 'git-commit', commit: boundedPlan.sourceCommit,
|
||||
});
|
||||
|
||||
writeFileSync(join(checkout, 'bin', 'torch.mjs'), 'export const marker = "dirty";\n');
|
||||
assert.throws(
|
||||
() => service.planCandidate({ source: checkout }),
|
||||
(error) => error.code === 'CANDIDATE_SOURCE_DIRTY',
|
||||
);
|
||||
});
|
||||
|
||||
test('SCN-atomic-upgrade-rollback: a crash after pointer swap reconciles and rollback restores the exact prior version', () => {
|
||||
|
||||
Reference in new issue
Block a user