Files
qinglong/test/back/localArtifactRead.test.cjs
T

608 lines
16 KiB
JavaScript

require('ts-node/register/transpile-only');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const { test } = require('node:test');
const { Sequelize } = require('sequelize');
const {
defineSchemaMigrationModel,
} = require('../../back/data/schemaMigration');
const {
RUN_ATTEMPT_TABLE,
RUN_TABLE,
runSchemaMigration,
} = require('../../back/migrations/0002-run-schema');
const {
LOCAL_ARTIFACT_RETENTION_TABLE,
localArtifactRetentionMigration,
} = require('../../back/migrations/0015-local-artifact-retention');
const { runMigrations } = require('../../back/migrations/runner');
const {
LocalArtifactByteRangeReader,
UnsafeLocalArtifactReadTargetError,
} = require('../../back/runtime/adapters/fs/localArtifactByteRangeReader');
const {
LocalArtifactTruncationFactStore,
localArtifactTruncationFactFileName,
} = require('../../back/runtime/adapters/fs/localArtifactTruncationFactStore');
const {
LegacySequelizeLocalArtifactReadMetadataRepository,
} = require('../../back/runtime/adapters/legacy-sequelize/localArtifactReadMetadataRepository');
const {
LocalArtifactReadEvidenceConflictError,
LocalArtifactReadService,
} = require('../../back/runtime/application/localArtifactReadService');
const {
MAX_LOCAL_ARTIFACT_READ_BYTES,
} = require('../../back/runtime/domain/artifactRead');
const {
encodeLocalArtifactTruncationFact,
} = require('../../back/runtime/domain/localArtifactTruncation');
const RUN_ID = '019f7600-0000-7000-8000-000000000001';
const ATTEMPT_ID = '019f7600-0000-7000-8000-000000000002';
const LOG_ARTIFACT_ID = `local-${'e'.repeat(30)}`;
const PROJECT_ID = 'project-a';
const SUBJECT = Object.freeze({ type: 'user', id: 'user-1' });
const RANGE = Object.freeze({ offset: 2, length: 4 });
const METADATA = Object.freeze({
projectId: PROJECT_ID,
runId: RUN_ID,
attemptId: ATTEMPT_ID,
logArtifactId: LOG_ARTIFACT_ID,
});
function request(overrides = {}) {
return {
subject: SUBJECT,
projectId: PROJECT_ID,
runId: RUN_ID,
logArtifactId: LOG_ARTIFACT_ID,
range: RANGE,
...overrides,
};
}
async function temporaryArtifactRoot(t) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ql3-read-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
await fs.chmod(root, 0o700);
return root;
}
async function writeArtifact(root, value = '0123456789') {
const directory = path.join(root, LOG_ARTIFACT_ID.slice(6, 8));
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
const target = path.join(directory, `${LOG_ARTIFACT_ID}.log`);
await fs.writeFile(target, value, { mode: 0o600 });
return { directory, target };
}
function fact(quotaReached) {
return {
schemaVersion: 1,
runId: RUN_ID,
attemptId: ATTEMPT_ID,
logArtifactId: LOG_ARTIFACT_ID,
maximumBytes: 64 * 1024,
quotaReached,
observedAtMs: 1_800_000_000_000,
};
}
function service(overrides = {}) {
return new LocalArtifactReadService(
overrides.metadata ?? {
async find() {
return METADATA;
},
},
overrides.authorizer ?? {
async authorize() {
return 'allow';
},
},
overrides.bytes ?? {
async read() {
return {
status: 'available',
content: Buffer.from('2345'),
start: 2,
endExclusive: 6,
totalBytes: 10,
nextOffset: 6,
};
},
},
overrides.facts ?? {
async read() {
return null;
},
},
);
}
test('validates bounded range before metadata or policy side effects', async () => {
const calls = [];
const reader = service({
metadata: {
async find() {
calls.push('metadata');
return METADATA;
},
},
authorizer: {
async authorize() {
calls.push('authorize');
return 'allow';
},
},
bytes: {
async read() {
calls.push('bytes');
return { status: 'missing' };
},
},
facts: {
async read() {
calls.push('facts');
return null;
},
},
});
await assert.rejects(
reader.read(
request({
range: { offset: 0, length: MAX_LOCAL_ARTIFACT_READ_BYTES + 1 },
}),
),
/length is invalid/,
);
assert.deepEqual(calls, []);
});
test('never touches file or truncation evidence before artifact.read allows it', async () => {
const calls = [];
const reader = service({
metadata: {
async find(input) {
calls.push(['metadata', input]);
return METADATA;
},
},
authorizer: {
async authorize(input) {
calls.push(['authorize', input]);
return 'deny';
},
},
bytes: {
async read() {
calls.push(['bytes']);
throw new Error('must not read');
},
},
facts: {
async read() {
calls.push(['facts']);
throw new Error('must not read');
},
},
});
assert.deepEqual(await reader.read(request()), {
status: 'forbidden',
effect: 'deny',
});
assert.equal(calls.length, 2);
assert.equal(calls[0][0], 'metadata');
assert.deepEqual(calls[1], [
'authorize',
{
action: 'artifact.read',
subject: SUBJECT,
projectId: PROJECT_ID,
runId: RUN_ID,
logArtifactId: LOG_ARTIFACT_ID,
},
]);
});
test('returns not_found without policy or file probes for an unbound Artifact', async () => {
const calls = [];
const reader = service({
metadata: {
async find() {
calls.push('metadata');
return null;
},
},
authorizer: {
async authorize() {
calls.push('authorize');
return 'allow';
},
},
bytes: {
async read() {
calls.push('bytes');
return { status: 'missing' };
},
},
facts: {
async read() {
calls.push('facts');
return null;
},
},
});
assert.deepEqual(await reader.read(request()), { status: 'not_found' });
assert.deepEqual(calls, ['metadata']);
});
test('reads a bounded file snapshot and preserves true, false and unknown truncation', async (t) => {
const root = await temporaryArtifactRoot(t);
const { directory } = await writeArtifact(root);
const bytes = new LocalArtifactByteRangeReader(root);
const facts = new LocalArtifactTruncationFactStore(root);
const factTarget = path.join(
directory,
localArtifactTruncationFactFileName(LOG_ARTIFACT_ID),
);
const reader = service({ bytes, facts });
const unknown = await reader.read(request());
assert.equal(unknown.status, 'available');
assert.equal(unknown.content.toString(), '2345');
assert.deepEqual(
{
start: unknown.start,
endExclusive: unknown.endExclusive,
totalBytes: unknown.totalBytes,
nextOffset: unknown.nextOffset,
truncation: unknown.truncation,
},
{
start: 2,
endExclusive: 6,
totalBytes: 10,
nextOffset: 6,
truncation: { truncated: 'unknown' },
},
);
for (const quotaReached of [false, true]) {
await fs.writeFile(
factTarget,
encodeLocalArtifactTruncationFact(fact(quotaReached)),
{ mode: 0o600 },
);
const result = await reader.read(request());
assert.deepEqual(result.truncation, {
truncated: quotaReached,
maximumBytes: 64 * 1024,
observedAtMs: 1_800_000_000_000,
});
}
});
test('returns retained without touching files and resolves an ENOENT retirement race', async () => {
const retention = Object.freeze({
disposition: 'deleted',
finishedAtMs: 100,
eligibleAtMs: 200,
bytesReclaimed: 10,
recordedAtMs: 300,
});
let fileReads = 0;
let factReads = 0;
const alreadyRetained = service({
metadata: {
async find() {
return { ...METADATA, retention };
},
},
bytes: {
async read() {
fileReads += 1;
return { status: 'missing' };
},
},
facts: {
async read() {
factReads += 1;
return null;
},
},
});
const retained = await alreadyRetained.read(request());
assert.equal(retained.status, 'retained');
assert.deepEqual(retained.retention, retention);
assert.deepEqual(retained.truncation, { truncated: 'unknown' });
assert.equal(fileReads, 0);
assert.equal(factReads, 0);
let lookups = 0;
const raced = service({
metadata: {
async find() {
lookups += 1;
return lookups === 1 ? METADATA : { ...METADATA, retention };
},
},
bytes: {
async read() {
fileReads += 1;
return { status: 'missing' };
},
},
facts: {
async read() {
factReads += 1;
return null;
},
},
});
assert.equal((await raced.read(request())).status, 'retained');
assert.equal(lookups, 2);
assert.equal(fileReads, 1);
assert.equal(factReads, 0);
});
test('distinguishes unexplained missing content and rejects drifted fact identity', async () => {
const missing = service({
bytes: {
async read() {
return { status: 'missing' };
},
},
facts: {
async read() {
return fact(true);
},
},
});
const result = await missing.read(request());
assert.equal(result.status, 'missing');
assert.deepEqual(result.truncation, {
truncated: true,
maximumBytes: 64 * 1024,
observedAtMs: 1_800_000_000_000,
});
const drifted = service({
facts: {
async read() {
return {
...fact(false),
attemptId: '019f7600-0000-7000-8000-000000000099',
};
},
},
});
await assert.rejects(
drifted.read(request()),
LocalArtifactReadEvidenceConflictError,
);
});
test('file reader refuses symlink files and shard escapes', async (t) => {
const root = await temporaryArtifactRoot(t);
const directory = path.join(root, LOG_ARTIFACT_ID.slice(6, 8));
await fs.mkdir(directory, { mode: 0o700 });
const outside = path.join(root, 'outside.log');
await fs.writeFile(outside, 'outside');
const target = path.join(directory, `${LOG_ARTIFACT_ID}.log`);
await fs.symlink(outside, target);
const reader = new LocalArtifactByteRangeReader(root);
await assert.rejects(
reader.read(LOG_ARTIFACT_ID, RANGE),
UnsafeLocalArtifactReadTargetError,
);
await fs.unlink(target);
await fs.rmdir(directory);
await fs.symlink(path.dirname(outside), directory);
await assert.rejects(
reader.read(LOG_ARTIFACT_ID, RANGE),
UnsafeLocalArtifactReadTargetError,
);
});
async function setupDatabase(t) {
const database = new Sequelize({
dialect: 'sqlite',
storage: ':memory:',
logging: false,
});
t.after(() => database.close());
await runMigrations({
database,
migrationModel: defineSchemaMigrationModel(database),
migrations: [runSchemaMigration, localArtifactRetentionMigration],
logger: { info() {} },
});
return database;
}
async function seedMetadata(database, overrides = {}) {
const values = {
projectId: PROJECT_ID,
runId: RUN_ID,
attemptId: ATTEMPT_ID,
logArtifactId: LOG_ARTIFACT_ID,
executionOwner: 'runtime',
executorType: 'local_process',
...overrides,
};
const query = database.getQueryInterface();
await query.bulkInsert(RUN_TABLE, [
{
id: values.runId,
project_id: values.projectId,
task_id: 'task-1',
task_revision: 'revision-1',
trigger_type: 'manual',
execution_origin: 'manual',
execution_owner: values.executionOwner,
status: 'succeeded',
version: 1,
event_sequence: 1,
priority: 0,
created_at_ms: 1,
finished_at_ms: 100,
},
]);
await query.bulkInsert(RUN_ATTEMPT_TABLE, [
{
id: values.attemptId,
run_id: values.runId,
attempt: 1,
status: 'succeeded',
executor_type: values.executorType,
log_artifact_id: values.logArtifactId,
callback_sequence: 0,
created_at_ms: 1,
finished_at_ms: 100,
},
]);
return values;
}
test('SQLite metadata binds project, runtime owner, local executor and tombstone', async (t) => {
const database = await setupDatabase(t);
const values = await seedMetadata(database);
const repository = new LegacySequelizeLocalArtifactReadMetadataRepository(
database,
);
assert.equal(
await repository.find({
projectId: 'other-project',
runId: RUN_ID,
logArtifactId: LOG_ARTIFACT_ID,
}),
null,
);
assert.deepEqual(
await repository.find({
projectId: PROJECT_ID,
runId: RUN_ID,
logArtifactId: LOG_ARTIFACT_ID,
}),
METADATA,
);
await database
.getQueryInterface()
.bulkInsert(LOCAL_ARTIFACT_RETENTION_TABLE, [
{
attempt_id: values.attemptId,
log_artifact_id: values.logArtifactId,
finished_at_ms: 100,
eligible_at_ms: 200,
disposition: 'deleted',
bytes_reclaimed: 10,
recorded_at_ms: 300,
},
]);
const retained = await repository.find({
projectId: PROJECT_ID,
runId: RUN_ID,
logArtifactId: LOG_ARTIFACT_ID,
});
assert.deepEqual(retained.retention, {
disposition: 'deleted',
finishedAtMs: 100,
eligibleAtMs: 200,
bytesReclaimed: 10,
recordedAtMs: 300,
});
});
test('SQLite metadata excludes legacy owner and non-local executor', async (t) => {
const legacyDatabase = await setupDatabase(t);
await seedMetadata(legacyDatabase, { executionOwner: 'legacy' });
const legacy = new LegacySequelizeLocalArtifactReadMetadataRepository(
legacyDatabase,
);
assert.equal(
await legacy.find({
projectId: PROJECT_ID,
runId: RUN_ID,
logArtifactId: LOG_ARTIFACT_ID,
}),
null,
);
const remoteDatabase = await setupDatabase(t);
await seedMetadata(remoteDatabase, { executorType: 'remote_worker' });
const remote = new LegacySequelizeLocalArtifactReadMetadataRepository(
remoteDatabase,
);
assert.equal(
await remote.find({
projectId: PROJECT_ID,
runId: RUN_ID,
logArtifactId: LOG_ARTIFACT_ID,
}),
null,
);
});
test('SQLite metadata rejects a tombstone bound to a different Artifact', async (t) => {
const database = await setupDatabase(t);
const values = await seedMetadata(database);
await database
.getQueryInterface()
.bulkInsert(LOCAL_ARTIFACT_RETENTION_TABLE, [
{
attempt_id: values.attemptId,
log_artifact_id: `local-${'f'.repeat(30)}`,
finished_at_ms: 100,
eligible_at_ms: 200,
disposition: 'already_absent',
bytes_reclaimed: 0,
recorded_at_ms: 300,
},
]);
const repository = new LegacySequelizeLocalArtifactReadMetadataRepository(
database,
);
await assert.rejects(
repository.find({
projectId: PROJECT_ID,
runId: RUN_ID,
logArtifactId: LOG_ARTIFACT_ID,
}),
/metadata is corrupt or ambiguous/,
);
});
test('SQLite metadata rejects a tombstone with drifted Attempt completion time', async (t) => {
const database = await setupDatabase(t);
const values = await seedMetadata(database);
await database
.getQueryInterface()
.bulkInsert(LOCAL_ARTIFACT_RETENTION_TABLE, [
{
attempt_id: values.attemptId,
log_artifact_id: values.logArtifactId,
finished_at_ms: 101,
eligible_at_ms: 200,
disposition: 'deleted',
bytes_reclaimed: 10,
recorded_at_ms: 300,
},
]);
const repository = new LegacySequelizeLocalArtifactReadMetadataRepository(
database,
);
await assert.rejects(
repository.find({
projectId: PROJECT_ID,
runId: RUN_ID,
logArtifactId: LOG_ARTIFACT_ID,
}),
/metadata is corrupt or ambiguous/,
);
});