mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-30 12:09:15 +00:00
48f010475b
* feat(gitops): resolve branch, tag, and SHA refs to immutable commits before fetch The ref model now resolves a configured branch, tag, or full commit SHA to an immutable commit before any content is downloaded, and records both the configured and the resolved identity where revision state persists. - RefKind (branch | tag | sha) is a resolved property, not caller-asserted. A bare string resolves branch-first, then tag; a full 40/64-hex SHA self-resolves with no remote round-trip. Branch and tag both fetch via a bare --branch name; a SHA uses init + shallow fetch + detached checkout. - A single ls-remote with narrow heads/tags refspecs pins the configured ref to an immutable SHA; rev-parse HEAD must equal the resolved SHA or the fetch refuses (tip-changed) instead of materializing unreviewed content. - Error union grew: REF_NOT_FOUND (ref-neutral, replaces BRANCH_NOT_FOUND), UNSUPPORTED_REF (a pinned SHA the host will not serve), and a service-level REF_DELETED upgrade that fires when a classified REF_NOT_FOUND occurs for a source with prior fetch history (a vanished ref reads as delete/force-push, not a fresh typo). Status mapping: REF_NOT_FOUND/REF_DELETED to 404, UNSUPPORTED_REF to 400. - Configured-vs-resolved identity is recorded via a nullable resolved_ref_kind column on gitops_generations (added to CREATE TABLE and re-added for legacy installs through maybeAddCol). The kind is deliberately NOT in the plan fingerprint: two sources naming the same commit differently are the same plan. Docs updated (git-sources feature page, connect-a-git-source tutorial, and the native-git-transport internal deep-dive) to the ref-neutral naming. * fix(gitops): harden ref resolution after pre-merge audit Request peeled annotated-tag refs from ls-remote, detect force-pushes and ref-kind changes against prior fetch identity, persist resolved kind on application rows, and add real-git tag/SHA integration coverage plus ref-neutral UI and operator docs. * test(gitops): mock verifyFastForward in direct producer suite The producer tests stub the transport seam but were missing resolved kind on resolveRef and a verifyFastForward stub, so second pulls tripped the new ref-continuity checks as REF_DELETED. * test(git): remove unused buildBareFixtureRepo helper Fixes backend lint failure after the integration fixture was refactored to buildRichFixtureRepo without dropping the old wrapper. * fix(gitops): correct fast-forward ancestry verification under size bounds Replace the dual shallow-fetch ancestry probe with a single-tip deepen strategy, keep verifier Git work inside the transport watchdog, and add real-Git regression coverage for linear advances and rewritten history. * fix(gitops): bound fast-forward verification with exponential deepen Replace per-commit deepen loops with exponential steps, cap remote fetch rounds, and share one deadline across verifier Git calls. Budget exhaustion now surfaces as a classified timeout instead of REF_DELETED. * fix(gitops): classify fast-forward probe failures accurately Normalize verifier probe timeouts and unexpected exit codes into transport failures, interpret merge-base status 1 as proven non-ancestry only, and treat shallow stagnation as timeout instead of REF_DELETED. * fix(gitops): satisfy tsc on probeFailure never returns * fix(gitops): address Phase E QA findings on ref verification Remove the fast-forward scratch repo after verification so pull size caps are not inflated, classify GitHub not-our-ref as UNSUPPORTED_REF, persist fetched_resolved_ref_kind on create-from-git, and broaden REF_DELETED copy for retagged tags.
446 lines
18 KiB
TypeScript
446 lines
18 KiB
TypeScript
/**
|
|
* Boot-time settlement of creates that a previous process left in flight.
|
|
*
|
|
* Each case seeds the durable state a crash would have left at one phase, runs
|
|
* the recovery the startup sweep runs, and asserts the outcome: finish the
|
|
* create only when its project is already on disk, tear it down only after its
|
|
* files are gone, and never touch a source row that outlived the application.
|
|
*/
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
|
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
|
|
import { DatabaseService } from '../services/DatabaseService';
|
|
import { GitOpsStore } from '../services/gitops/store';
|
|
import { GitOpsTransitions, type EventEnvelope } from '../services/gitops/transitions';
|
|
import { assertCreatesSettled, resolveInterruptedCreates } from '../services/gitops/createRecovery';
|
|
import { candidateRelPathForSha, CREATE_STAGING_MARKER_FILENAME } from '../services/gitops/createStagingMarker';
|
|
import { stackManagedRoot } from '../services/gitops/directApplication';
|
|
import type {
|
|
GitOpsApplicationRow,
|
|
GitOpsCreateCheckpointRow,
|
|
GitOpsGenerationRow,
|
|
} from '../services/gitops/types';
|
|
|
|
const SHA = 'feed1234';
|
|
|
|
describe('gitops interrupted create recovery', () => {
|
|
let tmpDir: string;
|
|
|
|
beforeAll(async () => {
|
|
tmpDir = await setupTestDb();
|
|
GitOpsStore.resetForTests();
|
|
GitOpsTransitions.resetForTests();
|
|
});
|
|
|
|
afterAll(() => {
|
|
cleanupTestDb(tmpDir);
|
|
});
|
|
|
|
beforeEach(() => {
|
|
const db = DatabaseService.getInstance().getDb();
|
|
db.prepare('DELETE FROM gitops_create_checkpoints').run();
|
|
db.prepare('DELETE FROM gitops_history').run();
|
|
db.prepare('DELETE FROM gitops_target_current').run();
|
|
db.prepare('DELETE FROM gitops_generations').run();
|
|
db.prepare('DELETE FROM gitops_applications').run();
|
|
});
|
|
|
|
it('tears down a create that stopped before the stack existed', async () => {
|
|
const store = GitOpsStore.getInstance();
|
|
seedCreate('app-pre', 'pre-stack-web', 'pre_stack');
|
|
const managedRoot = stackManagedRoot('pre-stack-web');
|
|
fs.mkdirSync(path.join(managedRoot, candidateRelPathForSha(SHA)), { recursive: true });
|
|
|
|
const settled = await resolveInterruptedCreates();
|
|
|
|
expect(settled).toEqual([
|
|
{ stackName: 'pre-stack-web', applicationId: 'app-pre', outcome: 'tombstoned' },
|
|
]);
|
|
expect(store.getApplication('app-pre')?.lifecycle_status).toBe('deleted');
|
|
expect(store.getCreateCheckpoint('app-pre')).toBeUndefined();
|
|
expect(store.getLiveDirectApplication('pre-stack-web')).toBeUndefined();
|
|
expect(fs.existsSync(managedRoot)).toBe(false);
|
|
});
|
|
|
|
it('leaves a stack directory alone when the create never recorded making it', async () => {
|
|
// pre_stack is durable proof that createStack had not returned, so a
|
|
// directory present now may be the operator's own. Deleting it is the one
|
|
// mistake recovery cannot take back.
|
|
seedCreate('app-notours', 'notours-web', 'pre_stack');
|
|
const composeDir = process.env.COMPOSE_DIR!;
|
|
const stackDir = path.join(composeDir, 'notours-web');
|
|
fs.mkdirSync(stackDir, { recursive: true });
|
|
fs.writeFileSync(path.join(stackDir, 'compose.yaml'), 'services: {}\n');
|
|
|
|
const settled = await resolveInterruptedCreates();
|
|
|
|
expect(settled[0].outcome).toBe('tombstoned');
|
|
expect(fs.existsSync(path.join(stackDir, 'compose.yaml'))).toBe(true);
|
|
});
|
|
|
|
it('removes the stack directory a crashed create had already made', async () => {
|
|
seedCreate('app-mid', 'mid-web', 'stack_created');
|
|
const composeDir = process.env.COMPOSE_DIR!;
|
|
fs.mkdirSync(path.join(composeDir, 'mid-web'), { recursive: true });
|
|
fs.writeFileSync(path.join(composeDir, 'mid-web', 'compose.yaml'), 'services: {}\n');
|
|
|
|
const settled = await resolveInterruptedCreates();
|
|
|
|
expect(settled[0].outcome).toBe('tombstoned');
|
|
expect(fs.existsSync(path.join(composeDir, 'mid-web'))).toBe(false);
|
|
});
|
|
|
|
it('preserves a managed root the create did not create', async () => {
|
|
seedCreate('app-shared', 'shared-web', 'pre_stack', { createdManagedRoot: 0 });
|
|
const managedRoot = stackManagedRoot('shared-web');
|
|
const sentinel = path.join(managedRoot, 'generations', 'applied-earlier');
|
|
fs.mkdirSync(sentinel, { recursive: true });
|
|
fs.mkdirSync(path.join(managedRoot, candidateRelPathForSha(SHA)), { recursive: true });
|
|
|
|
await resolveInterruptedCreates();
|
|
|
|
expect(fs.existsSync(sentinel)).toBe(true);
|
|
expect(fs.existsSync(path.join(managedRoot, candidateRelPathForSha(SHA)))).toBe(false);
|
|
});
|
|
|
|
it('finishes a create whose manifest was already committed on disk', async () => {
|
|
const store = GitOpsStore.getInstance();
|
|
const db = DatabaseService.getInstance();
|
|
seedCreate('app-finish', 'finish-web', 'manifest_committed');
|
|
const composeDir = process.env.COMPOSE_DIR!;
|
|
fs.mkdirSync(path.join(composeDir, 'finish-web'), { recursive: true });
|
|
|
|
const settled = await resolveInterruptedCreates();
|
|
|
|
expect(settled[0].outcome).toBe('completed');
|
|
const app = store.getApplication('app-finish')!;
|
|
expect(app.lifecycle_status).toBe('active');
|
|
expect(app.accepted_generation_id).toBe('gen-app-finish');
|
|
expect(app.source_acceptance_ref).not.toBeNull();
|
|
expect(store.getTarget('app-finish', 1)?.applied_generation_id).toBe('gen-app-finish');
|
|
expect(db.getGitSource('finish-web')?.last_applied_commit_sha).toBe(SHA);
|
|
expect(store.getCreateCheckpoint('app-finish')).toBeUndefined();
|
|
});
|
|
|
|
it('clears the checkpoint of a create that already reached its boundary', async () => {
|
|
const store = GitOpsStore.getInstance();
|
|
seedCreate('app-done', 'done-web', 'pointers_committed');
|
|
DatabaseService.getInstance().getDb().prepare(
|
|
"UPDATE gitops_applications SET lifecycle_status = 'active' WHERE id = 'app-done'",
|
|
).run();
|
|
|
|
const settled = await resolveInterruptedCreates();
|
|
|
|
expect(settled[0].outcome).toBe('checkpoint_cleared');
|
|
expect(store.getApplication('app-done')?.lifecycle_status).toBe('active');
|
|
expect(store.getCreateCheckpoint('app-done')).toBeUndefined();
|
|
});
|
|
|
|
it('tombstones a creating application with no checkpoint and keeps its source row', async () => {
|
|
const store = GitOpsStore.getInstance();
|
|
const db = DatabaseService.getInstance();
|
|
seedCreate('app-orphan', 'orphan-web', 'pre_stack');
|
|
store.deleteCreateCheckpoint('app-orphan');
|
|
db.upsertGitSource({
|
|
stack_name: 'orphan-web',
|
|
repo_url: 'https://github.com/org/repo.git',
|
|
branch: 'main',
|
|
compose_path: 'compose.yml',
|
|
compose_paths: ['compose.yml'],
|
|
context_dir: null,
|
|
sync_env: false,
|
|
env_path: null,
|
|
auth_type: 'none',
|
|
encrypted_token: null,
|
|
auto_apply_on_webhook: false,
|
|
auto_deploy_on_apply: false,
|
|
last_applied_commit_sha: SHA,
|
|
last_applied_content_hash: null,
|
|
pending_commit_sha: null,
|
|
pending_compose_content: null,
|
|
pending_env_content: null,
|
|
pending_fetched_at: null,
|
|
last_debounce_at: null,
|
|
});
|
|
|
|
const settled = await resolveInterruptedCreates();
|
|
|
|
expect(settled).toEqual([
|
|
{ stackName: 'orphan-web', applicationId: 'app-orphan', outcome: 'source_preserved' },
|
|
]);
|
|
expect(store.getApplication('app-orphan')?.lifecycle_status).toBe('deleted');
|
|
expect(db.getGitSource('orphan-web')).toBeTruthy();
|
|
expect(store.getLiveDirectApplication('orphan-web')).toBeUndefined();
|
|
});
|
|
|
|
it('retains a create whose files could not be removed, and refuses to start on it', async () => {
|
|
// A cleanup that cannot finish must not be recorded as a clean failure: the
|
|
// application stays `creating`, so nothing downstream may treat the stack
|
|
// name as free. The failure is forced through the containment guard, which
|
|
// is a real refusal rather than a stubbed one.
|
|
const store = GitOpsStore.getInstance();
|
|
seedCreate('app-stuck', 'stuck-web', 'stack_created', { createdManagedRoot: 0 });
|
|
store.updateCreateCheckpoint('app-stuck', { generationId: 'gen-app-stuck' }, Date.now());
|
|
const managedRoot = stackManagedRoot('stuck-web');
|
|
// Outside the managed area, which is what makes the guard refuse.
|
|
const external = path.join(process.env.DATA_DIR!, 'external-stuck');
|
|
fs.mkdirSync(external, { recursive: true });
|
|
fs.mkdirSync(managedRoot, { recursive: true });
|
|
fs.symlinkSync(external, path.join(managedRoot, 'generations'), 'junction');
|
|
|
|
const settled = await resolveInterruptedCreates();
|
|
|
|
expect(settled).toEqual([
|
|
{ stackName: 'stuck-web', applicationId: 'app-stuck', outcome: 'retained' },
|
|
]);
|
|
// Still creating, and its checkpoint survives so the next boot retries.
|
|
expect(store.getApplication('app-stuck')?.lifecycle_status).toBe('creating');
|
|
expect(store.getCreateCheckpoint('app-stuck')).toBeDefined();
|
|
expect(fs.existsSync(external)).toBe(true);
|
|
|
|
// What startup does with that outcome: stop, before any mutation service
|
|
// starts or HTTP binds.
|
|
expect(() => assertCreatesSettled(settled)).toThrow(/stuck-web/);
|
|
});
|
|
|
|
it('reports a settled create whose marker survived, and still starts', async () => {
|
|
// The counterpart to the test above, driven through the real code rather
|
|
// than a hand-built outcome list. Ownership is decided here, so a marker
|
|
// file that could not be deleted decides nothing and must not stop a boot.
|
|
// The marker path is made a directory so the unlink fails while everything
|
|
// else about the create is already settled.
|
|
const store = GitOpsStore.getInstance();
|
|
seedCreate('app-marker', 'marker-web', 'pointers_committed');
|
|
DatabaseService.getInstance().getDb().prepare(
|
|
"UPDATE gitops_applications SET lifecycle_status = 'active' WHERE id = 'app-marker'",
|
|
).run();
|
|
fs.mkdirSync(path.join(stackManagedRoot('marker-web'), CREATE_STAGING_MARKER_FILENAME), { recursive: true });
|
|
|
|
const settled = await resolveInterruptedCreates();
|
|
|
|
expect(settled).toEqual([
|
|
{ stackName: 'marker-web', applicationId: 'app-marker', outcome: 'marker_retained' },
|
|
]);
|
|
// The checkpoint is what makes the next boot retry the marker. Dropping it
|
|
// would leave a claim on the name with nothing left to clear it.
|
|
expect(store.getCreateCheckpoint('app-marker')).toBeDefined();
|
|
expect(() => assertCreatesSettled(settled)).not.toThrow();
|
|
});
|
|
|
|
it('clears the marker before the checkpoint for a create that is no longer creating', async () => {
|
|
// An application tombstoned on some other path leaves a stale checkpoint
|
|
// behind. It settles like any other finished create, and it has to clear the
|
|
// marker on the way out: dropping the checkpoint first would leave a claim
|
|
// on the stack name with nothing left to retry it, and every later create
|
|
// for that name would be refused by a marker nothing could remove.
|
|
const store = GitOpsStore.getInstance();
|
|
seedCreate('app-gone', 'gone-web', 'stack_created', { createdManagedRoot: 0 });
|
|
DatabaseService.getInstance().getDb().prepare(
|
|
"UPDATE gitops_applications SET lifecycle_status = 'deleted' WHERE id = 'app-gone'",
|
|
).run();
|
|
// A directory at the marker path, so the unlink fails the way a permission
|
|
// error would and the ordering becomes observable.
|
|
fs.mkdirSync(path.join(stackManagedRoot('gone-web'), CREATE_STAGING_MARKER_FILENAME), { recursive: true });
|
|
|
|
const settled = await resolveInterruptedCreates();
|
|
|
|
expect(settled).toEqual([
|
|
{ stackName: 'gone-web', applicationId: 'app-gone', outcome: 'marker_retained' },
|
|
]);
|
|
expect(store.getCreateCheckpoint('app-gone')).toBeDefined();
|
|
expect(() => assertCreatesSettled(settled)).not.toThrow();
|
|
});
|
|
|
|
it('drops the checkpoint for a create that is no longer creating once its marker is clear', async () => {
|
|
// The same route with nothing blocking the marker: this is the ordinary
|
|
// outcome, and it must still end with the checkpoint gone.
|
|
const store = GitOpsStore.getInstance();
|
|
seedCreate('app-gone-ok', 'gone-ok-web', 'stack_created', { createdManagedRoot: 0 });
|
|
DatabaseService.getInstance().getDb().prepare(
|
|
"UPDATE gitops_applications SET lifecycle_status = 'deleted' WHERE id = 'app-gone-ok'",
|
|
).run();
|
|
|
|
const settled = await resolveInterruptedCreates();
|
|
|
|
expect(settled[0].outcome).toBe('checkpoint_cleared');
|
|
expect(store.getCreateCheckpoint('app-gone-ok')).toBeUndefined();
|
|
});
|
|
|
|
it('reports a marker left by a torn-down create without blocking the boot', async () => {
|
|
// The teardown path reaches the same condition by a different route. Its
|
|
// staged directories are gone, so nothing deployable survives and the
|
|
// create is effectively torn down; only the marker is stuck. Treating that
|
|
// as unresolved would make one failed unlink cost an operator their
|
|
// instance, which is the opposite of the settled path's answer.
|
|
const store = GitOpsStore.getInstance();
|
|
seedCreate('app-tearmark', 'tearmark-web', 'stack_created', { createdManagedRoot: 0 });
|
|
fs.mkdirSync(path.join(stackManagedRoot('tearmark-web'), CREATE_STAGING_MARKER_FILENAME), { recursive: true });
|
|
|
|
const settled = await resolveInterruptedCreates();
|
|
|
|
expect(settled[0].outcome).toBe('marker_retained');
|
|
expect(store.getCreateCheckpoint('app-tearmark')).toBeDefined();
|
|
expect(() => assertCreatesSettled(settled)).not.toThrow();
|
|
});
|
|
|
|
it('settles a create when the managed area is not on disk at all', async () => {
|
|
// A database restored without its data directory, or a volume that failed
|
|
// to mount. Nothing under the area exists, so there is nothing to remove
|
|
// and the create tears down normally. Reporting this as unresolved would,
|
|
// with the boot gate, stop the instance starting on every boot over a
|
|
// directory that is merely absent.
|
|
const previous = process.env.DATA_DIR;
|
|
process.env.DATA_DIR = path.join(tmpDir, 'data-without-managed-area');
|
|
try {
|
|
seedCreate('app-noarea', 'noarea-web', 'stack_created', { createdManagedRoot: 0 });
|
|
const settled = await resolveInterruptedCreates();
|
|
expect(settled[0].outcome).toBe('tombstoned');
|
|
expect(() => assertCreatesSettled(settled)).not.toThrow();
|
|
} finally {
|
|
process.env.DATA_DIR = previous;
|
|
}
|
|
});
|
|
|
|
it('is idempotent across repeated boots', async () => {
|
|
seedCreate('app-replay', 'replay-web', 'pre_stack');
|
|
const first = await resolveInterruptedCreates();
|
|
const second = await resolveInterruptedCreates();
|
|
expect(first[0].outcome).toBe('tombstoned');
|
|
expect(second).toEqual([]);
|
|
});
|
|
});
|
|
|
|
function seedCreate(
|
|
applicationId: string,
|
|
stackName: string,
|
|
phase: GitOpsCreateCheckpointRow['phase'],
|
|
options: { createdManagedRoot?: number } = {},
|
|
): void {
|
|
const store = GitOpsStore.getInstance();
|
|
const generationId = `gen-${applicationId}`;
|
|
GitOpsTransitions.getInstance().activateCreateFromGit({
|
|
application: creatingApp(applicationId, stackName),
|
|
nodeId: 1,
|
|
commitSha: SHA,
|
|
generation: gen(generationId, applicationId),
|
|
checkpoint: {
|
|
application_id: applicationId,
|
|
stack_name: stackName,
|
|
phase: 'pre_stack',
|
|
generation_id: null,
|
|
operation_id: `op-${applicationId}`,
|
|
repo_url: 'https://github.com/org/repo.git',
|
|
branch: 'main',
|
|
compose_path: 'compose.yml',
|
|
compose_paths_json: '["compose.yml"]',
|
|
context_dir: null,
|
|
sync_env: 0,
|
|
env_path: null,
|
|
auth_type: 'none',
|
|
encrypted_token: null,
|
|
auto_apply_on_webhook: 0,
|
|
auto_deploy_on_apply: 0,
|
|
commit_sha: SHA,
|
|
applied_spec_json: null,
|
|
created_managed_root: options.createdManagedRoot ?? 1,
|
|
created_at: 1,
|
|
updated_at: 1,
|
|
},
|
|
envelope: envelope(`op-${applicationId}`),
|
|
});
|
|
if (phase !== 'pre_stack') {
|
|
store.updateCreateCheckpoint(applicationId, { phase }, Date.now());
|
|
}
|
|
}
|
|
|
|
function envelope(operationId: string): EventEnvelope {
|
|
return { operationId, actor: 'tester', trigger: 'manual', at: Date.now() };
|
|
}
|
|
|
|
function creatingApp(id: string, stackName: string): GitOpsApplicationRow {
|
|
return {
|
|
id,
|
|
lifecycle_key: `direct:${stackName}`,
|
|
lifecycle_status: 'creating',
|
|
target_mode: 'direct',
|
|
stack_name: stackName,
|
|
blueprint_id: null,
|
|
configured_repo_url: 'https://github.com/org/repo.git',
|
|
repo_identity_json: '{"host":"github.com","pathname":"/org/repo.git"}',
|
|
configured_ref: 'main',
|
|
compose_paths_json: '["compose.yml"]',
|
|
context_dir: null,
|
|
sync_env: 0,
|
|
env_path: null,
|
|
materialization_fingerprint: 'a'.repeat(64),
|
|
desired_commit_sha: null,
|
|
fetched_commit_sha: null,
|
|
fetched_resolved_ref_kind: null,
|
|
candidate_generation_id: null,
|
|
accepted_generation_id: null,
|
|
candidate_plan_blocked: 0,
|
|
review_required: 0,
|
|
artifact_set_id: null,
|
|
latest_artifact_set_id: null,
|
|
intent_revision_id: null,
|
|
rollout_candidate_id: null,
|
|
rollout_generation_id: null,
|
|
source_acceptance_ref: null,
|
|
placement_approval_ref: null,
|
|
rollout_authorization_ref: null,
|
|
legacy_combined_approval_ref: null,
|
|
preflight_fingerprint: null,
|
|
latest_operation_id: null,
|
|
active_operation_id: null,
|
|
active_operation_stage: null,
|
|
active_operation_at: null,
|
|
active_generation_id: null,
|
|
pause_at: null,
|
|
pause_reason: null,
|
|
partial_json: null,
|
|
failure_stage: null,
|
|
failure_class: null,
|
|
failure_at: null,
|
|
retry_at: null,
|
|
retry_count: 0,
|
|
suspended_at: null,
|
|
recovery_ref: null,
|
|
recovery_phase: null,
|
|
interruption_stage: null,
|
|
interruption_at: null,
|
|
interruption_operation_id: null,
|
|
interruption_generation_id: null,
|
|
evidence_fresh_at: null,
|
|
evidence_limitations_json: null,
|
|
created_at: 1,
|
|
updated_at: 1,
|
|
};
|
|
}
|
|
|
|
function gen(id: string, applicationId: string): GitOpsGenerationRow {
|
|
return {
|
|
id,
|
|
application_id: applicationId,
|
|
commit_sha: SHA,
|
|
repo_url: 'https://github.com/org/repo.git',
|
|
resolved_ref_kind: 'branch',
|
|
configured_ref: 'main',
|
|
repo_identity_json: '{"host":"github.com","pathname":"/org/repo.git"}',
|
|
manifest_version: 1,
|
|
candidate_dir: candidateRelPathForSha(SHA),
|
|
applied_dir: `generations/applied-${SHA}-1`,
|
|
expected_invocation_json: '{"composeFileOrder":[],"projectName":null,"projectDirectory":null,"envFileOrder":[]}',
|
|
materialization_fingerprint: 'a'.repeat(64),
|
|
validation_ok: 1,
|
|
plan_blocked: 0,
|
|
change_plan_fingerprint: null,
|
|
operation_id: `op-${id}`,
|
|
trigger: 'manual',
|
|
actor: 'tester',
|
|
previous_generation_id: null,
|
|
redacted_limitations_json: '[]',
|
|
created_at: 1,
|
|
};
|
|
}
|