Files
sencho/backend/src/__tests__/gitops-direct-producers.test.ts
T
Anso 392bc15d91 feat(git): swap isomorphic-git for native git transport behind clone seam (#1849)
* feat(git): swap isomorphic-git for native git transport behind clone seam

Replace the isomorphic-git engine (HTTP-only, single importer) with the
native git CLI behind the existing withClonedRepo seam, so SSH deploy
keys, ref semantics, and private CAs become reachable in later PRs.

- resolve-before-fetch: ls-remote pins the branch to an immutable SHA,
  then rev-parse verifies the checkout against it; tip races refuse
- hardened spawns: argv arrays only, protocol allowlist (https only),
  neutralized hooks, isolated HOME and all config channels, no prompts
- token reaches git only via a credential helper reading SENCHO_GIT_TOKEN
  from the child env; never argv or URL
- size cap becomes a workspace watchdog (on-disk measure) keeping the
  same knob and breach message; deterministic final gate added
- Windows: pin http.sslBackend=openssl (schannel ignores sslCAInfo) and
  anchor to Git's bundled CA; NODE_EXTRA_CA_CERTS combines with platform
  defaults instead of replacing them
- error classification retargets to exit code + stderr while preserving
  the contractual mappings (AUTH_FAILED maps to 400, never 401;
  unauthenticated refusals mask as REPO_NOT_FOUND)
- runtime image installs git; tests re-pointed at the transport boundary
  plus a new engine suite (classifier corpus, argv hardening, watchdog)

Zero externally visible behavior change except two edge cases: an empty
branch now surfaces BRANCH_NOT_FOUND, and a mid-fetch force push refuses
instead of materializing the moved tip.

* fix(git): unblock CI on linux kill-path test and codeql log warning

Two CI-only findings from the first pipeline run:

- The scripted spawn child in the transport tests lacked the kill method
  that killTree's POSIX fallback reaches when a fake process group does
  not exist; Linux runs crashed inside the timeout tests while Windows
  (taskkill branch) could not reproduce it. Give the fixture the method
  the real ChildProcess always has.
- CodeQL flagged the workspace-removal warning that interpolated the
  NODE_EXTRA_CA_CERTS path (environment-sourced values are treated as
  sensitive at log sinks). Reword the warning to name the variable
  instead of its value; operators know their own environment.

* fix(git): collapse remaining duplicated test setup so the shared helper is used

* fix(git): close watchdog, size-gate, ref-validator, and kill-ordering gaps in native transport

Resolves the release-blocking findings from an independent pre-merge audit
of the native git transport swap:

- A watchdog-triggered kill mid-clone was misclassified as a generic exit
  failure instead of a size breach, because runGit resolves (not rejects)
  when the child is killed via SIGKILL.
- The final on-disk size measurement failed open when it could not be
  read (workspace removed mid-walk, permissions), letting an unmeasured
  clone through as a success. Now fails closed and logs the real cause.
- The ref-name validator was an overly restrictive allow-list that
  rejected valid branch names (leading underscore, non-ASCII, '#').
  Replaced with a deny-list matching real `git check-ref-format --branch`
  semantics, verified against the git binary, including a per-path-segment
  `.lock` check the first pass missed.
- runGit's timeout handler settled as soon as a kill was issued rather
  than confirmed, racing workspace cleanup against a still-alive child
  tree. It now waits for the child's close event, with a bounded fallback
  if termination is never confirmed, and preserves the timeout
  classification if 'error' fires after the kill.
- Windows killTree now also falls back to child.kill() when taskkill
  itself exits non-zero, not just when it fails to spawn.
- Added a real, non-mocked integration test that drives the credential
  helper through the actual git binary against a local HTTPS server with
  Basic Auth checking. It caught a genuine bug the mocked suite could not
  see: the credential.helper config value was quoted in a way that broke
  git's own absolute-path helper detection, failing every authenticated
  clone. Fixed by removing the quotes.
- Migrated a separately developed test file's mocks off the deleted
  isomorphic-git module onto the native transport seam, matching the
  pattern already used elsewhere, after merging with main pulled in that
  feature.

Also updates two stale comments left over from the isomorphic-git era and
adds a git version check to the Docker runtime image smoke tests.

* fix(git): make credential-helper path safe, unify ref length, and fix Windows kill ordering

Addresses three PR 1 correction items from pre-merge audit:

- credential.helper is a shell string, not argv: interpolating the
  helper's workspace-relative path broke authenticated fetches whenever
  the workspace sat under a directory with a space in its name. The
  config value is now a fixed string that names an environment
  variable instead, so no workspace path character can affect how
  git's shell parses it.
- The transport rejected branch names over 200 characters while the
  route accepted up to 256 and real git has no comparable limit.
  REF_MAX_LEN is now a single exported constant shared by the
  transport and both routes.
- On Windows, taskkill runs as a separate process and could still be
  walking a killed process tree after the direct git child reported
  closed, letting the caller delete the workspace early. Kill
  operations are now awaited to completion (bounded by a timeout)
  before a timed-out or size-breached run settles, on both the close
  and error event paths.

Verified against a real authenticated git server inside the built
runtime image: public HTTPS, private HTTPS with a valid PAT, invalid
PAT, a deleted branch, an oversized repository, and the awkward
workspace-path case, including from a workspace path containing
spaces and shell metacharacters.

* fix(git): reap killed helpers and classify curl refusals
2026-08-28 02:00:08 +00:00

690 lines
28 KiB
TypeScript

/**
* End-to-end coverage for the Direct Git producers.
*
* Only the two boundaries the host owns are stubbed: `git.clone` writes a real
* project into the clone directory and `git.log` returns a commit, and the
* compose commands that need a running daemon report an exit code each test
* chooses. Compose commands that only parse files, `config` above all, still
* shell out for real, so the Compose CLI is a genuine prerequisite here even
* though the daemon is not. Everything after that runs for real, so fetch,
* candidate materialization, change-plan classification, the apply, the
* deploy, and the detach all drive the GitOps state model the way they do in
* production.
*
* This exists because the producer wiring is the seam between the operational
* Git path and the revision state, and a mismatch there type-checks and passes
* transition-level tests.
*/
import { EventEmitter } from 'events';
import fsPromises from 'fs/promises';
import path from 'path';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
const { mockResolveRef, mockFetchAtCommit, mockGitClone, mockGitLog, compose } = vi.hoisted(() => ({
mockResolveRef: vi.fn(),
mockFetchAtCommit: vi.fn(),
mockGitClone: vi.fn(),
mockGitLog: vi.fn(),
/** Exit code the next daemon-dependent compose command reports. */
compose: { exitCode: 1 },
}));
// The transport boundary is what gets mocked (see git-source-service.test.ts,
// which established this seam). mockGitClone/mockGitLog remain as the
// fixture layer so stageRepo() keeps its meaning: clone writes files into
// the checkout dir, log yields the deterministic sha.
vi.mock('../services/git/nativeGitTransport', () => ({
nativeGitTransport: {
resolveRef: mockResolveRef,
fetchAtCommit: mockFetchAtCommit,
},
}));
/**
* Compose verbs that need a running daemon and are issued through `spawn`.
*
* `ps` is deliberately absent: it is issued through `execFile`, which this mock
* does not replace, so listing it would advertise coverage that is not there.
*/
const DAEMON_COMPOSE_VERBS = new Set(['up', 'down', 'pull', 'build', 'start', 'stop', 'restart']);
const COMPOSE_FLAGS_WITH_VALUE = new Set([
'-f', '--file', '-p', '--project-name', '--env-file', '--project-directory',
]);
/**
* The verb in a `docker compose …` argv, skipping global flags and their
* values so a stack or file named after a verb cannot be mistaken for one.
*/
function composeVerbOf(args: readonly string[]): string | null {
if (args[0] !== 'compose') return null;
for (let i = 1; i < args.length; i++) {
const token = args[i];
if (COMPOSE_FLAGS_WITH_VALUE.has(token)) {
i++;
continue;
}
if (token.startsWith('-')) continue;
return token;
}
return null;
}
function fakeComposeChild(): EventEmitter {
const child = new EventEmitter() as EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
kill: () => boolean;
};
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.kill = () => true;
// The caller attaches its listeners synchronously after spawn returns, so the
// exit cannot be announced until the current turn finishes.
setImmediate(() => child.emit('close', compose.exitCode));
return child;
}
/**
* Only the compose commands that need a daemon are answered here; `config` and
* everything else still runs for real.
*
* That split is the whole point. A deploy failing is otherwise a fact about the
* host rather than about the adapter: a workstation with the CLI but no daemon
* parses compose files happily and fails `up`, while a CI runner succeeds at
* both, so any test that reads "the deploy failed" from the environment says
* something different in the two places.
*/
vi.mock('child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('child_process')>();
return {
...actual,
spawn: (command: string, args: readonly string[], options?: unknown) => {
if (command === 'docker' && DAEMON_COMPOSE_VERBS.has(composeVerbOf(args) ?? '')) {
return fakeComposeChild();
}
return (actual.spawn as unknown as (...a: unknown[]) => unknown)(command, args, options);
},
};
});
// Rollback capture talks to Docker, which is not available here. Stubbing it
// keeps the apply on its success path so the GitOps wiring is what the test
// actually exercises.
vi.mock('../services/StackUpdateRecoveryService', () => ({
StackUpdateRecoveryService: {
getInstance: () => ({
captureCandidate: vi.fn(async () => ({ id: 'rec-producers-1' })),
abandon: vi.fn(async () => true),
markAcquired: vi.fn(() => true),
handoff: vi.fn(() => true),
markReconciling: vi.fn(() => true),
markImmediateVerified: vi.fn(() => true),
get: vi.fn(() => ({ id: 'rec-producers-1', is_current: 1 })),
linkGateOrRetain: vi.fn(),
compensateWithCandidate: vi.fn(async () => true),
start: vi.fn(),
}),
},
}));
const REPO = 'https://github.com/example/project.git';
const COMPOSE = 'services:\n web:\n image: nginx:1.27\n';
const COMPOSE_V2 = 'services:\n web:\n image: nginx:1.28\n';
const COMPOSE_PROD = 'services:\n web:\n restart: always\n';
let tmpDir: string;
let GitSourceService: typeof import('../services/GitSourceService').GitSourceService;
let GitOpsStore: typeof import('../services/gitops/store').GitOpsStore;
let GitOpsTransitions: typeof import('../services/gitops/transitions').GitOpsTransitions;
let projectApplication: typeof import('../services/gitops/derive').projectApplication;
/**
* Default transport wiring: resolveRef defers to the log stub so per-test
* overrides of mockGitLog keep controlling the final SHA, and fetchAtCommit
* delegates to the clone/log fixture fns, handing clone a `dir` that points
* at the workspace checkout. Mirrors git-source-service.test.ts's
* wireTransportDefaults.
*/
function wireTransportDefaults(): void {
mockResolveRef.mockImplementation(async () => {
const log = await mockGitLog({});
const oid = Array.isArray(log) ? log[0]?.oid : undefined;
return { commitSha: oid ?? '' };
});
mockFetchAtCommit.mockImplementation(async (req: { workspaceRoot: string; commitSha: string }) => {
const dir = path.join(req.workspaceRoot, 'repo');
await fsPromises.mkdir(dir, { recursive: true });
await mockGitClone({ ...req, dir });
const log = await mockGitLog({ dir });
if (!Array.isArray(log) || !log.length) {
// An empty branch produces no remote ref; mirror the structured
// failure the real transport raises for that case.
throw { transportFailure: true as const, reason: 'ref-not-found', host: 'unknown', hasToken: false };
}
return { commitSha: log[0].oid, dir };
});
}
/** Make the next clone produce a project containing this compose content. */
function stageRepo(content: string, sha: string, extraFiles: Record<string, string> = {}): void {
mockGitClone.mockImplementation(async ({ dir }: { dir: string }) => {
await fsPromises.mkdir(dir, { recursive: true });
await fsPromises.writeFile(path.join(dir, 'compose.yaml'), content, 'utf8');
for (const [name, body] of Object.entries(extraFiles)) {
await fsPromises.writeFile(path.join(dir, name), body, 'utf8');
}
});
mockGitLog.mockResolvedValue([{ oid: sha }]);
}
function projectOf(applicationId: string) {
const projection = projectApplication(applicationId, true);
if (projection.targetMode === 'not_applicable') throw new Error('expected an application');
return projection;
}
describe('Direct Git producers drive the revision state', () => {
beforeAll(async () => {
tmpDir = await setupTestDb();
({ GitSourceService } = await import('../services/GitSourceService'));
({ GitOpsStore } = await import('../services/gitops/store'));
({ GitOpsTransitions } = await import('../services/gitops/transitions'));
({ projectApplication } = await import('../services/gitops/derive'));
GitOpsStore.resetForTests();
GitOpsTransitions.resetForTests();
});
afterAll(() => {
cleanupTestDb(tmpDir);
});
beforeEach(() => {
mockResolveRef.mockReset();
mockFetchAtCommit.mockReset();
mockGitClone.mockReset();
mockGitLog.mockReset();
wireTransportDefaults();
compose.exitCode = 1;
});
// Prototype spies a test installs are restored here rather than in a per-test
// finally, so a failing test cannot leak one into the next. Only spies are
// touched, so the hoisted git mocks keep the reset above.
afterEach(() => {
vi.restoreAllMocks();
});
it('creates, fetches, applies, and detaches a Git stack through the state model', async () => {
const svc = GitSourceService.getInstance();
const store = GitOpsStore.getInstance();
const stackName = 'producers-web';
// ── create ────────────────────────────────────────────────────────────
stageRepo(COMPOSE, 'aaaaaaa1');
await svc.createStackFromGit({
stackName,
repoUrl: REPO,
branch: 'main',
composePaths: ['compose.yaml'],
contextDir: null,
syncEnv: false,
envPath: null,
authType: 'none',
token: null,
autoApplyOnWebhook: false,
autoDeployOnApply: false,
});
const app = store.getLiveDirectApplication(stackName);
expect(app).toBeTruthy();
if (!app) throw new Error('expected an application');
expect(app.lifecycle_status).toBe('active');
expect(app.accepted_generation_id).not.toBeNull();
expect(app.desired_commit_sha).toBe('aaaaaaa1');
// The create records a secret-free identity, never the operational URL.
expect(app.configured_repo_url).toBe('https://github.com/example/project.git');
// The success boundary cleared its own checkpoint.
expect(store.getCreateCheckpoint(app.id)).toBeUndefined();
const afterCreate = projectOf(app.id);
expect(afterCreate.facets.source.status).toBe('application_generation_accepted');
expect(afterCreate.targets[0]?.runtime.status).toBe('applied_not_deployed');
// ── fetch a newer commit ──────────────────────────────────────────────
stageRepo(COMPOSE_V2, 'bbbbbbb2');
await svc.pull(stackName, { actor: 'tester' });
const afterPull = store.getApplication(app.id)!;
expect(afterPull.desired_commit_sha).toBe('bbbbbbb2');
expect(afterPull.fetched_commit_sha).toBe('bbbbbbb2');
// A fetch advances the resolved commit and offers a candidate, but the
// accepted generation does not move until the apply.
expect(afterPull.accepted_generation_id).toBe(app.accepted_generation_id);
expect(afterPull.candidate_generation_id).not.toBeNull();
expect(afterPull.candidate_generation_id).not.toBe(app.accepted_generation_id);
const candidateId = afterPull.candidate_generation_id!;
const candidate = store.getGeneration(candidateId)!;
expect(candidate.commit_sha).toBe('bbbbbbb2');
expect(candidate.application_id).toBe(app.id);
expect(candidate.materialization_fingerprint).toBe(afterPull.materialization_fingerprint);
expect(store.getTarget(app.id, 1)?.candidate_generation_id).toBe(candidateId);
expect(projectOf(app.id).availableActions).toContain('apply');
// ── apply ─────────────────────────────────────────────────────────────
await svc.apply(stackName, 'bbbbbbb2', { requirePlanFingerprint: false, deploy: false, actor: 'tester' });
const afterApply = store.getApplication(app.id)!;
expect(afterApply.accepted_generation_id).toBe(candidateId);
expect(afterApply.candidate_generation_id).toBeNull();
expect(afterApply.active_operation_stage).toBeNull();
expect(afterApply.source_acceptance_ref).not.toBeNull();
const target = store.getTarget(app.id, 1)!;
expect(target.desired_generation_id).toBe(candidateId);
expect(target.applied_generation_id).toBe(candidateId);
expect(target.candidate_generation_id).toBeNull();
// The acceptance is provable against the exact generation it authorized.
expect(store.resolveApprovalRef(afterApply.source_acceptance_ref!, {
kind: 'source_acceptance',
applicationId: app.id,
generationId: candidateId,
})).toBeTruthy();
expect(store.resolveApprovalRef(afterApply.source_acceptance_ref!, {
kind: 'source_acceptance',
applicationId: app.id,
generationId: app.accepted_generation_id!,
})).toBeNull();
// ── detach ────────────────────────────────────────────────────────────
await svc.detach(stackName);
expect(store.getLiveDirectApplication(stackName)).toBeUndefined();
const tombstoned = store.getApplication(app.id)!;
expect(tombstoned.lifecycle_status).toBe('detached');
// Configured identity and resolved commit survive as frozen facts.
expect(tombstoned.configured_repo_url).toBe('https://github.com/example/project.git');
expect(tombstoned.desired_commit_sha).toBe('bbbbbbb2');
expect(store.getTarget(app.id, 1)?.target_status).toBe('tombstoned');
expect(projectOf(app.id).facets.source.status).toBe('not_live');
});
it('binds the deployed generation through the Compose adapter', async () => {
const { ComposeService } = await import('../services/ComposeService');
const { default: DockerController } = await import('../services/DockerController');
const svc = GitSourceService.getInstance();
const store = GitOpsStore.getInstance();
const stackName = 'producers-deploy';
stageRepo(COMPOSE, '11111111');
await svc.createStackFromGit({
stackName,
repoUrl: REPO,
branch: 'main',
composePaths: ['compose.yaml'],
contextDir: null,
syncEnv: false,
envPath: null,
authType: 'none',
token: null,
autoApplyOnWebhook: false,
autoDeployOnApply: false,
});
const app = store.getLiveDirectApplication(stackName)!;
const applied = store.getTarget(app.id, 1)!.applied_generation_id;
expect(applied).not.toBeNull();
expect(store.getTarget(app.id, 1)?.deployed_generation_id).toBeNull();
// The post-deploy probe asks the daemon what came up. It is not what this
// test is about, and `getInstance` hands back a fresh controller each call,
// so the stubs go on the prototype. `afterEach` restores them, so a failing
// test cannot leak one into the next.
const composeSvc = ComposeService.getInstance(1);
vi.spyOn(DockerController.prototype, 'getLegacyOrphanContainersByStack')
.mockResolvedValue([]);
vi.spyOn(DockerController.prototype, 'getDocker')
.mockReturnValue({ listContainers: async () => [] } as unknown as ReturnType<
typeof DockerController.prototype.getDocker
>);
// ── the compose command fails ──────────────────────────────────────
compose.exitCode = 1;
await expect(composeSvc.deployStack(stackName)).rejects.toThrow();
const failed = store.getTarget(app.id, 1)!;
expect(failed.deployed_generation_id).toBeNull();
expect(failed.failure_stage).toBe('deploy');
// Classified conservatively: once the compose command has been handed
// off, we cannot prove the workload was untouched, and claiming it was
// intact would be the more dangerous error.
expect(failed.failure_class).toBe('post_mutation');
expect(projectOf(app.id).targets[0]?.runtime.status).toBe('failed_after_mutation');
expect(failed.applied_generation_id).toBe(applied);
// ── the compose command succeeds ───────────────────────────────────
compose.exitCode = 0;
const result = await composeSvc.deployStack(stackName);
// The adapter reports the generation it bound, which is what lets the
// caller start health against that exact generation rather than against
// whatever is applied by the time health runs.
expect(result.deployedGenerationId).toBe(applied);
const bound = store.getTarget(app.id, 1)!;
expect(bound.deployed_generation_id).toBe(applied);
expect(bound.applied_generation_id).toBe(applied);
// A bound deploy clears the earlier failure: the target is no longer in
// the state the operator was asked to act on.
expect(bound.failure_stage).toBeNull();
expect(bound.failure_class).toBeNull();
expect(projectOf(app.id).targets[0]?.runtime.status).not.toBe('failed_after_mutation');
});
it('reports the deployed generation from an update so health can bind to it', async () => {
const { ComposeService } = await import('../services/ComposeService');
const { StackUpdateOrchestrator } = await import('../services/StackUpdateOrchestrator');
const svc = GitSourceService.getInstance();
const store = GitOpsStore.getInstance();
const stackName = 'producers-update';
stageRepo(COMPOSE, '22222222');
await svc.createStackFromGit({
stackName,
repoUrl: REPO,
branch: 'main',
composePaths: ['compose.yaml'],
contextDir: null,
syncEnv: false,
envPath: null,
authType: 'none',
token: null,
autoApplyOnWebhook: false,
autoDeployOnApply: false,
});
const app = store.getLiveDirectApplication(stackName)!;
const applied = store.getTarget(app.id, 1)!.applied_generation_id;
// The image pull fails, so the update dies during preparation, before the
// recreate Compose is handed anything. The deploy operation is opened only
// at that recreate, so nothing is recorded: an update that never touched
// the workload must not leave a deploy failure behind for the deriver to
// report.
compose.exitCode = 1;
await expect(ComposeService.getInstance(1).updateStack(stackName)).rejects.toThrow();
const target = store.getTarget(app.id, 1)!;
expect(target.deployed_generation_id).toBeNull();
expect(target.failure_stage).toBeNull();
expect(target.active_operation_stage).toBeNull();
expect(target.applied_generation_id).toBe(applied);
expect(projectOf(app.id).targets[0]?.runtime.status).toBe('applied_not_deployed');
// The same holds through the orchestrator, which is what the update callers
// actually use and which carries the binding on to beginStack.
await expect(StackUpdateOrchestrator.getInstance().execute(
{ nodeId: 1, stackName, target: { scope: 'stack' }, trigger: 'manual', actor: 'tester' },
{ atomic: false, terminalWs: null },
)).rejects.toThrow();
expect(store.getTarget(app.id, 1)?.failure_stage).toBeNull();
});
it('records a failed fetch without moving any pointer', async () => {
const svc = GitSourceService.getInstance();
const store = GitOpsStore.getInstance();
const stackName = 'producers-fail';
stageRepo(COMPOSE, 'ccccccc3');
await svc.createStackFromGit({
stackName,
repoUrl: REPO,
branch: 'main',
composePaths: ['compose.yaml'],
contextDir: null,
syncEnv: false,
envPath: null,
authType: 'none',
token: null,
autoApplyOnWebhook: false,
autoDeployOnApply: false,
});
const app = store.getLiveDirectApplication(stackName)!;
const acceptedBefore = app.accepted_generation_id;
mockGitClone.mockRejectedValue(new Error('could not resolve host'));
await expect(svc.pull(stackName, { actor: 'tester' })).rejects.toThrow();
const afterFailure = store.getApplication(app.id)!;
expect(afterFailure.failure_stage).toBe('fetch');
expect(afterFailure.active_operation_stage).toBeNull();
expect(afterFailure.accepted_generation_id).toBe(acceptedBefore);
expect(afterFailure.candidate_generation_id).toBeNull();
const projection = projectOf(app.id);
expect(projection.facets.source.status).toBe('source_failed');
expect(projection.availableActions).toContain('fetch');
// A later successful fetch clears the failure.
stageRepo(COMPOSE_V2, 'ddddddd4');
await svc.pull(stackName, { actor: 'tester' });
expect(store.getApplication(app.id)?.failure_stage).toBeNull();
});
it('brings a newly linked stack into the model and invalidates its candidate on a material edit', async () => {
const svc = GitSourceService.getInstance();
const store = GitOpsStore.getInstance();
const stackName = 'producers-link';
const composeDir = process.env.COMPOSE_DIR!;
await fsPromises.mkdir(path.join(composeDir, stackName), { recursive: true });
await fsPromises.writeFile(path.join(composeDir, stackName, 'compose.yaml'), COMPOSE, 'utf8');
stageRepo(COMPOSE, '33333333');
await svc.upsert({
stackName,
repoUrl: REPO,
branch: 'main',
composePaths: ['compose.yaml'],
contextDir: null,
syncEnv: false,
envPath: null,
authType: 'none',
token: null,
autoApplyOnWebhook: false,
autoDeployOnApply: false,
});
const app = store.getLiveDirectApplication(stackName);
expect(app).toBeTruthy();
if (!app) throw new Error('expected an application');
// Linked, not fetched: nothing is desired or accepted until a pull runs.
expect(app.lifecycle_status).toBe('active');
expect(app.desired_commit_sha).toBeNull();
expect(app.accepted_generation_id).toBeNull();
let projection = projectOf(app.id);
expect(projection.facets.source.status).toBe('never_reconciled');
expect(projection.availableActions).toContain('fetch');
// A pull produces a candidate against the current configuration.
stageRepo(COMPOSE_V2, '44444444');
await svc.pull(stackName, { actor: 'tester' });
const candidateId = store.getApplication(app.id)!.candidate_generation_id;
expect(candidateId).not.toBeNull();
// A credential-only edit changes nothing material, so the candidate stands.
stageRepo(COMPOSE_V2, '44444444');
await svc.upsert({
stackName,
repoUrl: REPO,
branch: 'main',
composePaths: ['compose.yaml'],
contextDir: null,
syncEnv: false,
envPath: null,
authType: 'none',
token: null,
autoApplyOnWebhook: true,
autoDeployOnApply: false,
});
expect(store.getApplication(app.id)?.candidate_generation_id).toBe(candidateId);
// Changing the compose file set does invalidate it: that candidate was
// built from a different set and can no longer be applied.
stageRepo(COMPOSE_V2, '44444444', { 'compose.prod.yaml': COMPOSE_PROD });
await svc.upsert({
stackName,
repoUrl: REPO,
branch: 'main',
composePaths: ['compose.yaml', 'compose.prod.yaml'],
contextDir: null,
syncEnv: false,
envPath: null,
authType: 'none',
token: null,
autoApplyOnWebhook: true,
autoDeployOnApply: false,
});
const afterEdit = store.getApplication(app.id)!;
expect(afterEdit.candidate_generation_id).toBeNull();
expect(afterEdit.desired_commit_sha).toBeNull();
expect(store.getTarget(app.id, 1)?.candidate_generation_id).toBeNull();
projection = projectOf(app.id);
expect(projection.availableActions).toContain('fetch');
expect(projection.availableActions).not.toContain('apply');
});
it('retires the application when the stack itself is deleted', async () => {
const { DeployedStackDeletionService } = await import('../services/DeployedStackDeletionService');
const svc = GitSourceService.getInstance();
const store = GitOpsStore.getInstance();
const stackName = 'producers-delete';
stageRepo(COMPOSE, '55555555');
await svc.createStackFromGit({
stackName,
repoUrl: REPO,
branch: 'main',
composePaths: ['compose.yaml'],
contextDir: null,
syncEnv: false,
envPath: null,
authType: 'none',
token: null,
autoApplyOnWebhook: false,
autoDeployOnApply: false,
});
const app = store.getLiveDirectApplication(stackName)!;
await DeployedStackDeletionService.getInstance().deleteDeployedStack({
nodeId: 1,
stackName,
pruneVolumes: false,
actor: 'tester',
});
// A deleted stack must not leave a live application behind: it would keep
// claiming the name and block re-creating it.
expect(store.getLiveDirectApplication(stackName)).toBeUndefined();
expect(store.getApplication(app.id)?.lifecycle_status).toBe('deleted');
expect(store.getTarget(app.id, 1)?.target_status).toBe('tombstoned');
});
it('closes the operation when a terminal transition is rejected', async () => {
const svc = GitSourceService.getInstance();
const store = GitOpsStore.getInstance();
const stackName = 'producers-reject';
stageRepo(COMPOSE, '66666666');
await svc.createStackFromGit({
stackName,
repoUrl: REPO,
branch: 'main',
composePaths: ['compose.yaml'],
contextDir: null,
syncEnv: false,
envPath: null,
authType: 'none',
token: null,
autoApplyOnWebhook: false,
autoDeployOnApply: false,
});
const app = store.getLiveDirectApplication(stackName)!;
// Reject the transition that closes a fetch. Recording must not fail the
// pull, but it must not leave the operation open either: a fetch that never
// terminates blocks every later pull from being recorded at all.
const tx = GitOpsTransitions.getInstance();
const realFetched = tx.fetched.bind(tx);
tx.fetched = () => { throw new Error('rejected for test'); };
try {
stageRepo(COMPOSE_V2, '77777777');
await svc.pull(stackName, { actor: 'tester' });
} finally {
tx.fetched = realFetched;
}
const afterReject = store.getApplication(app.id)!;
expect(afterReject.active_operation_stage).toBeNull();
expect(afterReject.failure_stage).toBe('fetch');
// The projection reports an error the operator can act on, not a spinner.
const projection = projectOf(app.id);
expect(projection.facets.source.status).toBe('source_failed');
expect(projection.availableActions).toContain('fetch');
// And the next pull records normally, rather than being locked out.
stageRepo(COMPOSE_V2, '88888888');
await svc.pull(stackName, { actor: 'tester' });
const recovered = store.getApplication(app.id)!;
expect(recovered.fetched_commit_sha).toBe('88888888');
expect(recovered.failure_stage).toBeNull();
expect(recovered.active_operation_stage).toBeNull();
});
it('leaves a stack with no GitOps application untouched', async () => {
const svc = GitSourceService.getInstance();
const store = GitOpsStore.getInstance();
const stackName = 'producers-legacy';
// A Git stack exactly as an install carries it across an upgrade: the
// source row was written before this model existed, so there is no
// application and nothing has migrated it yet. Seeded directly, because
// linking through the service now creates one.
const composeDir = process.env.COMPOSE_DIR!;
await fsPromises.mkdir(path.join(composeDir, stackName), { recursive: true });
await fsPromises.writeFile(path.join(composeDir, stackName, 'compose.yaml'), COMPOSE, 'utf8');
(await import('../services/DatabaseService')).DatabaseService.getInstance().upsertGitSource({
stack_name: stackName,
repo_url: REPO,
branch: 'main',
compose_path: 'compose.yaml',
compose_paths: ['compose.yaml'],
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: 'eeeeeee5',
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,
});
expect(store.getLiveDirectApplication(stackName)).toBeUndefined();
stageRepo(COMPOSE_V2, 'fffffff6');
await svc.pull(stackName, { actor: 'tester' });
// The pull succeeded operationally and wrote no GitOps rows.
expect(store.getLiveDirectApplication(stackName)).toBeUndefined();
const historyRows = (await import('../services/DatabaseService')).DatabaseService
.getInstance().getDb()
.prepare('SELECT COUNT(*) AS n FROM gitops_history WHERE stack_name = ?')
.get(stackName) as { n: number };
expect(historyRows.n).toBe(0);
});
});