mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-09 17:05:51 +00:00
69e76090cc
* feat(gitops): add reconcile trigger normalization and coalescing keys The source controller needs a single normalized shape for every trigger that can request evaluation (manual, API, webhook, poll, retry, config change, startup, resume, and future provider/schedule/binding-change producers), and a way to decide whether two concurrent submissions describe the same work. Add ReconcileTrigger, the discriminated ReconcileRequest (fetch vs apply), and coalesceKey/deliveryKey. A fetch coalesces by application alone, since it has one live outcome regardless of trigger; an apply coalesces only when the commit, plan fingerprint, and deploy choice all match, so two applies that differ in any of those can never be joined and have one silently receive the other's result. * feat(gitops): add exhaustive failure classification and retry backoff The controller needs to tell a transient network condition from a permanent configuration one from a broad range of Git-source, policy, and target failures, since several distinct causes collapse onto the same public error code and a wrong call either retries forever on a bad URL or gives up on a DNS blip. Add classifyFailure, built on two total lookup records (one keyed by TransportFailureReason, one by GitSourceErrorCode) so adding a new value to either source union fails the build until this classifier accounts for it, rather than silently defaulting. A tip-changed race classifies as supersession, not backoff; an unrecognized exit-coded git error gets a lower retry ceiling than a plain network timeout; a deploy or health failure after a successful apply is its own class that must never refetch or reapply. Add nextRetryAt: bounded exponential backoff (60s doubling, capped at one hour) with +-10% jitter, honoring a provider-supplied retry floor when it is larger than the computed delay. * feat(gitops): derive normalized reconcile outcomes from the source facet Silence is not an acceptable GitOps result: every reconcile attempt needs to settle into one named outcome an operator can act on, not a bare success/failure. Deriving that outcome from the existing source-facet projection (rather than a second, parallel status source) is what keeps "no source change" from silently collapsing into "converged" the way a bare commit-SHA comparison would. Add outcomeFromSourceFacet, exhaustive over all 17 SourceFacet statuses, each mapped to a ReconcileOutcome, a human reason, and a next action (review, resume, retry, resolve_conflict, configure_credentials, view_target_results, or none). converged is deliberately never produced here: it requires target and health evidence this source-only projection does not carry, so a later composition over source + target + health is the only thing allowed to report it. * feat(gitops): add the portable accepted-generation contract Direct and Blueprint dispatch need to consume the exact same description of what an accepted generation contains, without either side inventing a node id, local candidate path, target project name, or placement field into it, since that is exactly the kind of drift that would let a stale acceptance authorize a routing decision made after it. Add six additive, nullable columns to gitops_generations (portable manifest, Compose inputs, source/security policy evidence, support and compatibility requirements), all decoded honestly: a legacy row missing one records an explicit limitation rather than inventing evidence, and a legacy pending candidate lacking the new contract must be re-evaluated before it can be accepted or dispatched. Add gitops/handoff.ts: the AcceptedGeneration type built from a generation row via buildAcceptedGeneration, a compile-time assertion that the contract cannot carry a target-mode-specific field, and the TargetAdapter boundary with BlueprintTargetAdapter failing closed until Blueprint rollout orchestration exists. Current target mode and binding travel separately in DispatchContext, re-read under the dispatch lock rather than carried on the generation itself. * feat(gitops): add controller-owned bookkeeping columns to gitops_applications The source controller needs somewhere to persist source policy, poll cadence, and a durable attempt sequence per application, and it needs to work identically for Direct and Blueprint applications. stack_git_sources (the existing home for auto_apply_on_webhook/auto_deploy_on_apply) is Direct-only and keyed by stack name, so it cannot represent a Blueprint application at all. Add source_policy (manual|review|automatic, default manual), poll_interval_secs (NULL inherits the global default, 0 disables), next_poll_at (the durable scheduling cursor), and attempt_seq to gitops_applications instead. All default to values that start no unattended work: an upgraded installation begins polling nothing and stays on manual policy until explicitly migrated or configured. * feat(gitops): add durable attempt reservation and poll/retry queries History dedupe alone is not execution idempotency: mutateApp writes the application row and only then inserts history, so a dedupe conflict still commits the row write. The controller needs a reservation that runs before any side effect and touches nothing else, so a duplicate or restarted submission can be told apart from new work without repeating it. Add reserveReconcileAttempt/settleReconcileAttempt: a bare history insert in its own transaction, deliberately not through mutateApp, using the existing history dedupe index as the reservation/idempotency check itself. A repeated reservation or settlement for the same operation is a no-op, never overwriting the first settled result. Add the store queries a controller needs to drive this: getSettledAttempt and latestSettledAttempt (exact vs. most-recent lookup, the latter tie-broken by rowid since the id column is a random UUID unrelated to recency), listUnsettledReconcileAttempts (a reservation with no matching settled row, for startup recovery), and listSourcesDueForPoll / listApplicationsDueForRetry (excluding suspended, in-flight, and Blueprint-mode applications). * feat(gitops): add controller-facing reconcile entry point to GitSourceService Adds GitSourceService.reconcile(), a single normalized entry point that takes a fetch or apply request and returns a normalized outcome, next action, and reason instead of a thrown error or a raw boolean. It owns the git mutex for the whole evaluation and calls the same private fetch/apply bodies the existing pull()/apply() routes use, so it never nests locks. Resolves the live application for the stack first and fails closed, without attempting any work, when the request's application id no longer matches it or when no application exists at all. A fetch or apply failure that the underlying transition never persisted (missing config, a stale commit, lock contention) is classified through the existing retry disposition table so an unretryable failure is never reported as retryable, and a stale-looking success is never reported in place of a real failure. * feat(gitops): dispatch accepted generations to their target Adds GitSourceService.dispatchAcceptedGeneration(), which routes a portable accepted-generation contract to its target: Blueprint mode delegates to the existing BlueprintTargetAdapter (rollout orchestration is not built yet, so it always blocks), and Direct mode dispatches by driving reconcile() with the generation's commit, since there is no separate generation-based promotion pipeline yet. The deploy flag is read from the stack's own auto_deploy_on_apply setting, matching how every other producer decides it. Also fixes a case reconcile() got wrong: when a promotion succeeds but the following deploy fails, the source itself has genuinely changed, so reporting it as an unchanged failure was as untruthful as reporting it a plain success would have been. It now reports a result that neither claims, pointing at the target instead of asking for a source retry that could never succeed. * feat(gitops): add background poll and retry driver for source reconciliation Adds SourceController, a self-rescheduling background timer that finds sources whose poll interval or retry time has arrived and evaluates each through the existing reconcile() entry point. A per-application in-flight set is what keeps one slow evaluation from blocking the rest of the fleet; the tick never waits on any evaluation before scheduling the next one, and a scan that throws still reschedules rather than stopping the driver permanently. This delivery only covers detection: a tick issues a fetch, the same step a manual pull performs, so a new candidate is staged for review but not automatically accepted or dispatched, and a retry re-issues a plain fetch rather than resuming whatever stage previously failed. Both are documented as open follow-on work rather than silent gaps. Not yet wired into the process lifecycle; that follows separately. * feat(gitops): start and stop the source reconciliation driver with the process Wires SourceController into the background-service lifecycle: it starts after the existing GitOps recovery and orphan-sweep steps, so it never scans an application still carrying a stale in-progress marker from a killed process, and stops alongside every other background timer on shutdown. * feat(gitops): add suspend, resume, and explicit retry for a source Adds GitSourceService.suspend()/resume()/retry(), the service-layer methods behind an upcoming suspend/resume/retry control surface. Suspending a source now genuinely stops it: pullLocked and applyLockedBody both check suspension before doing any work, not just inside the transition bookkeeping, which used to reject but then let the fetch or apply proceed anyway. A refused suspend surfaces as a real error rather than a silent no-op, since an operator believing a source is suspended when it isn't is the exact failure this exists to prevent; a refused resume stays silent, since the row read back after the attempt already reports the true state either way. A webhook delivery to a suspended source is reported as skipped rather than a failed pull, so a long suspension does not read to the Git host as a broken webhook. * feat(gitops): add suspend, resume, and retry routes for a git source Adds POST endpoints for suspending a source (with an optional, length- capped reason), resuming it, and explicitly retrying it, wired to the existing service-layer methods behind the same stack:edit permission as pull and apply. Neither route can trigger a deploy today, so unlike apply and webhook-pull they need no conditional stack:deploy check. * fix(gitops): close path-injection gaps at two candidate-file sinks Adds the inline resolve-and-prefix-check barrier this codebase already uses at other filesystem sinks derived from a stack name or a stored candidate path, closing two sinks that lacked it: the manifest write in GitProjectManifestService, and the pending-candidate access check before promoting an apply. The apply-side check now shares the same strict candidate-path validator the registry-delivery path already uses on the identical stored field, rather than a looser check, so both call sites treat a tampered candidate reference the same way. Fixed two test fixtures that had never matched the shape a real candidate path takes, which the stricter check would otherwise have rejected. * fix(gitops): confine the manifest write directory to the managed area The prior fix checked the manifest write/rename targets against the resolved managed directory, but left the mkdir call on that directory itself unconfined and checked it against the data root rather than the narrower managed area every sibling barrier in this file uses. Aligns it with the established convention and the actual boundary that matters. * feat(gitops): wire durable attempt reservation and coalescing into reconcile() reconcile() now reserves a durable attempt before doing any fetch or apply and settles it with the normalized result once execution finishes, using the reservation and settlement primitives that already existed but had no caller. A request carrying a stable external delivery id reserves under a producer- and intent-namespaced key, so a redelivery reuses the same attempt instead of minting a second one; a request with no such identity gets a freshly allocated attemptSeq- based id, bumped atomically with its reservation. Concurrent submissions that would do the same work now coalesce: the first becomes the leader and actually runs, and any submission that joins while it is still in flight awaits the leader's real result instead of running a duplicate fetch or apply. Each still gets its own durable attempt and its own settled row, including a concurrent redelivery of the same external event, which joins the in-flight leader rather than falling back to a snapshot of the row from before the leader's work landed. Startup gains an attempt-recovery phase, run before the managed-area sweep and before SourceController starts: every attempt reserved but never settled, most likely from a crash between the two, is resolved from durable state without re-executing anything. One row failing to recover no longer blocks the rest; it is skipped and logged, and recovery keeps paging until nothing unsettled remains. A settlement failure is caught and logged rather than turning an already-successful fetch or apply into a thrown error for the caller, and a settled attempt's stored result is now decoded through a validated outcome/next-action check instead of a blind cast, logging rather than silently reporting unknown when a stored row is corrupt or unreadable. * fix(gitops): remove unused store variable from coalescing test * fix(gitops): make reconcile-attempt recovery leader-aware and cursor-paginated A fresh audit found real gaps in the reservation/coalescing wiring from the previous commit: recovery paged by "still unsettled" status rather than a cursor, so once a permanently unrecoverable row occupied every slot in a page, every genuinely recoverable row beyond it was silently never reached; a follower's outcome was reconstructed independently from row state rather than from its leader's actual stored result, so a leader and its follower could durably disagree; and an attempt resolved on the live path was returned but never actually settled, leaving it open indefinitely until the next restart. listUnsettledReconcileAttempts now takes an optional (created_at, id) cursor, matching the pagination shape queryHistoryRows already uses, so paging always advances regardless of which rows settle. Recovery is now two-pass: independent attempts settle first from row state, followers are deferred, then each deferred follower settles from its leader's now-settled result. A follower whose leader is a real but still-unresolved reservation is left unsettled rather than guessed at independently, since the leader could still settle to something else later, including when the leader simply failed to settle in this same pass rather than "never will". The same leader-aware resolution now backs the live reconcile() path too, via a shared helper, so an already-reserved attempt is durably settled instead of merely returning a value. Also fixes a narrower race in reconcile() itself: two submissions can share an operation id (a stable external delivery id) while running under different coalesce keys, since an apply's coalesce key includes its commit sha, plan fingerprint, and deploy flag, which the delivery id does not carry. reconcile() now checks in-process executions by operation id directly before falling back to a durable-state resolution, so such a submission joins the real in-flight leader instead of settling a stale pre-execution snapshot ahead of it. * feat(gitops): route pull, apply, and webhook producers through reservation A fresh audit found the same "primitive built, real caller does not use it" pattern one layer deeper than the previous commit fixed: reconcile(), the controller-facing entry point, reserved and coalesced durable attempts correctly, but the actual production producers, the manual pull button, the manual apply button, and the webhook route, all called pullLocked/applyWithSharedLock directly, bypassing reservation entirely. pullLocked also minted its own independent operation id rather than using a reserved attempt's, breaking the "one operation id spans an attempt and its stage evidence" invariant. pullLocked, applyWithSharedLock, applyLocked, and applyLockedBody now accept an optional operation id, using it in place of their own default when a caller supplies one. pull() and apply() reserve a durable attempt and coalesce with a concurrent call to themselves whenever a real GitOps application exists for the stack, the same definition pullLocked itself already used to decide whether it has any GitOps bookkeeping to do at all. handleWebhookPull()'s fetch step and its conditional auto-apply step reserve too, without a coalescing map: the route's own debounce window plus its single per-stack lock acquisition already prevent a concurrent duplicate from reaching that point, so there is nothing to coalesce there. Fixed along the way: apply()'s coalesce key computed its deploy flag differently than the code that actually executed the apply, so two concurrent applies that genuinely differed in deploy behavior could share a key and one could silently receive the other's result; deploy is now resolved once, the same way applyLockedBody itself resolves it, before it drives either the key or the execution. A policy-bypassing apply is now routed through a fresh, never-shared coalescing map, since bypassPolicy changes behavior but was not part of the key. A coalesced follower's own reservation is now settled in a finally rather than only after a successful await, so a rejecting leader (the ordinary path for a producer that preserves its own throw contract, unlike reconcile()'s internal error handling) no longer leaves the follower's attempt open until the next restart. A reservation bookkeeping failure no longer turns a manual pull or apply that would otherwise have succeeded into a hard failure; it logs and falls through to unreserved execution instead. * fix(gitops): make the boot sweep consult claimant pointers before reaping a candidate The boot-time managed-area sweep decided whether to delete a staged candidate directory purely from file age and a completeness marker, with no awareness of the database. A fresh audit gave the concrete failure: a reconcile stages a candidate, the process crashes before settlement, the installation stays down more than a day, startup settles the attempt from a snapshot rather than real stage evidence, and the sweep then deletes the still-needed candidate out from under it. sweepManagedArea now takes the set of candidate directory basenames still referenced by the stack, and never reaps one of them regardless of age or completeness. GitSourceService computes that set from three independent sources: the live application's current candidate generation, its accepted-but-not-yet-promoted generation (the sourceAccepted-committed, targetApplied-not-yet-committed window; that path has no production caller yet, so this is forward-looking coverage for it), and the pending fetch record's own candidate reference, which is written outside the transaction that mints a generation and can therefore be the only claimant for a candidate that failed validation or was staged while no live application existed to read a pointer from. * fix(gitops): fail closed on reservation failure and a torn-down application A fresh audit found that a reservation-bookkeeping failure (a transient DB error, an application torn down in the window between resolving it and reserving against it) fell through to unreserved execution. That directly defeated this delivery's own purpose: a manual apply could still promote Compose files and deploy with zero durable record of it happening. Reservation failure now fails closed for pull, apply, and the webhook route: the operation is refused, logged, and recorded to the stack's own activity history, distinguishing a torn-down application (never retryable) from a transient failure (worth retrying). Closing that hole surfaced a second, related gap: pull, apply, and the webhook route only checked for a live (active) application before deciding whether to reserve at all, so a stack whose GitOps tracking was explicitly torn down while its Git source configuration survived could still run fully untracked, the same failure mode reached a different way. Refusing this case took two attempts to get right, both caught by review before landing: the first version refused on any tombstoned state, which would have permanently and unrecoverably blocked pull and apply for an application deliberately tombstoned as deleted while its config survives for a future rebuild, a state two existing production paths produce on purpose; narrowing to detached only still misfired on a routine, fully completed detach, since that same operation deletes the source row in the same transaction, so the refusal must also confirm the source row actually survived before firing. Both are covered by regression tests now, alongside the original reservation-failure fix. * fix(gitops): unify fetch-intent coalescing across pull and reconcile A manual pull and a concurrently poll-triggered reconcile for the same application previously ran their own separate in-flight maps and could each start a clone for the same fetch. They now share one coalescing map, with reconcile's fetch path classifying its own outcome instead of relying on generic row-state derivation, so a pre-transition failure the row does not yet reflect is never durably recorded as a plain success. Apply-intent coalescing stays producer-local for now; unifying it needs deploy-failure awareness threaded into the shared settlement path first, which is a separately scoped follow-up. * feat(gitops): recognize a webhook delivery id for traceability The real webhook trigger endpoint is generic and HMAC-signed, with no delivery identity of its own. It now extracts one from a recognized provider header (GitHub, GitLab, Bitbucket, or a generic fallback) when the caller sends one, and threads it through to the git-pull execution path as a plain traceability breadcrumb on failure logs. Redelivery dedup is deliberately not implemented here: an earlier attempt routed the delivery id through the durable attempt reservation itself, which silently dropped a redelivery's history through the existing dedupe index instead of recording it. Building real dedup needs settlement to reflect classified outcomes rather than generic row-state derivation for both a settled and a crashed-mid-flight prior attempt, which is a wider, separately scoped change shared with the same gap already deferred for apply-intent reconcile. * fix(gitops): thread the reserved attempt's operation id into the pending fetch record The pending fetch record stamped its own independent random operation id instead of the reserved attempt's real one, so an apply falling back to it (when it holds no reservation of its own) inherited an identity unrelated to the fetch that actually produced the candidate. One id now spans reservation, fetch, generation, and the pending record. Also fixes the short "op" token rendered in activity log lines: a fixed prefix stopped discriminating between attempts once operation ids became structured (<applicationId>:attempt:<seq>), since the prefix is now the same applicationId every time. A shared helper renders the actual attempt-discriminating suffix instead, applied consistently across pull, apply, and create so each event's logged identity matches what its own durable history actually recorded. * test(gitops): cover the reconcile-recovery-then-sweep startup ordering Reconcile-attempt recovery and the managed-area sweep must run in that fixed order before the source controller's own poll loop starts, but the guarantee lived only in a comment inside startServer, a function with roughly two dozen unrelated service initializations that makes it impractical to exercise end to end in a test. Extracted the two steps into their own function so they're directly testable in isolation, without moving the source controller's own start call: that stays exactly where it was, since pulling it earlier would have crossed a separate, already-documented ordering requirement for registry delivery recovery. A structural test guards the one property that can't be covered by driving the function directly: that the real startServer body still calls the extracted function before starting the controller. * fix(gitops): complete durable reconciliation execution * fix(gitops): resolve static analysis findings
1026 lines
51 KiB
TypeScript
1026 lines
51 KiB
TypeScript
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
|
|
import { FACET_EVIDENCE_SOURCE } from '../services/gitops/types';
|
|
import { GitOpsStore, emptyTargetRow } from '../services/gitops/store';
|
|
import { GitOpsTransitions, type EventEnvelope } from '../services/gitops/transitions';
|
|
import { projectApplication } from '../services/gitops/derive';
|
|
import type { GitOpsApplicationRow, GitOpsGenerationRow } from '../services/gitops/types';
|
|
|
|
describe('gitops derivation', () => {
|
|
let tmpDir: string;
|
|
|
|
beforeAll(async () => {
|
|
tmpDir = await setupTestDb();
|
|
GitOpsStore.resetForTests();
|
|
GitOpsTransitions.resetForTests();
|
|
});
|
|
|
|
afterAll(() => {
|
|
cleanupTestDb(tmpDir);
|
|
});
|
|
|
|
it('registers every facet status exactly once', () => {
|
|
expect(FACET_EVIDENCE_SOURCE.source.applying).toBe('current');
|
|
expect(FACET_EVIDENCE_SOURCE.rollout.completion_unknown).toBe('current_or_future');
|
|
expect(FACET_EVIDENCE_SOURCE.source.source_superseded).toBe('future');
|
|
expect(FACET_EVIDENCE_SOURCE.runtime.rollout_artifact_drift).toBe('future');
|
|
expect(FACET_EVIDENCE_SOURCE.lkg.none).toBe('current');
|
|
});
|
|
|
|
it('projects applying with no fetch/apply/dismiss actions', () => {
|
|
const store = GitOpsStore.getInstance();
|
|
const tx = GitOpsTransitions.getInstance();
|
|
tx.activateDirect({ application: app('app-apply-facet', 'facet-web'), nodeId: 1, envelope: env('op-act') });
|
|
store.insertGeneration(gen('gen-facet', 'app-apply-facet'));
|
|
tx.fetchStarted('app-apply-facet', env('op-f'));
|
|
tx.fetched('app-apply-facet', 'abc123', env('op-f'));
|
|
tx.candidateReady('app-apply-facet', 'gen-facet', false, env('op-c'));
|
|
tx.applyStarted('app-apply-facet', 'gen-facet', env('op-a'));
|
|
const projection = projectApplication('app-apply-facet', false);
|
|
expect(projection.targetMode).toBe('direct');
|
|
if (projection.targetMode === 'not_applicable') throw new Error('expected application');
|
|
expect(projection.facets.source.status).toBe('applying');
|
|
expect(projection.availableActions).toEqual(['none']);
|
|
expect(projection.targets[0]?.desiredGenerationId).toBeNull();
|
|
expect(projection.targets[0]?.candidateGenerationId).toBe('gen-facet');
|
|
});
|
|
|
|
it('projects a freshly activated target as never applied and offers no deploy', () => {
|
|
const tx = GitOpsTransitions.getInstance();
|
|
tx.activateDirect({ application: app('app-idle', 'idle-web'), nodeId: 1, envelope: env('op-act-idle') });
|
|
const projection = projectApplication('app-idle', false);
|
|
if (projection.targetMode === 'not_applicable') throw new Error('expected application');
|
|
expect(projection.targets[0]?.runtime.status).toBe('never_applied');
|
|
expect(projection.availableActions).not.toContain('deploy');
|
|
expect(projection.availableActions).toContain('fetch');
|
|
});
|
|
|
|
it('keeps a never-applied target out of deploy actions when health gating is disabled', () => {
|
|
const tx = GitOpsTransitions.getInstance();
|
|
tx.activateDirect({ application: app('app-idle-nohealth', 'idle-nohealth-web'), nodeId: 1, envelope: env('op-act-idle-2') });
|
|
const projection = projectApplication('app-idle-nohealth', true);
|
|
if (projection.targetMode === 'not_applicable') throw new Error('expected application');
|
|
expect(projection.targets[0]?.runtime.status).toBe('never_applied');
|
|
expect(projection.availableActions).not.toContain('deploy');
|
|
});
|
|
|
|
it('projects accepted application and applied-not-deployed after apply', () => {
|
|
const store = GitOpsStore.getInstance();
|
|
const tx = GitOpsTransitions.getInstance();
|
|
tx.activateDirect({ application: app('app-done', 'done-web'), nodeId: 1, envelope: env('op-act-2') });
|
|
store.insertGeneration(gen('gen-done', 'app-done'));
|
|
tx.fetchStarted('app-done', env('op-f2'));
|
|
tx.fetched('app-done', 'abc123', env('op-f2'));
|
|
tx.candidateReady('app-done', 'gen-done', false, env('op-c2'));
|
|
tx.applied({
|
|
applicationId: 'app-done',
|
|
generationId: 'gen-done',
|
|
artifactSetId: 'art-done',
|
|
sourceAcceptanceId: 'acc-done',
|
|
authority: 'operator',
|
|
envelope: env('op-a2'),
|
|
});
|
|
const projection = projectApplication('app-done', false);
|
|
if (projection.targetMode === 'not_applicable') throw new Error('expected application');
|
|
expect(projection.facets.source.status).toBe('application_generation_accepted');
|
|
expect(projection.facets.artifact.status).toBe('artifact_resolution_pending');
|
|
expect(projection.facets.placement.status).toBe('unbound_direct');
|
|
expect(projection.targets[0]?.runtime.status).toBe('applied_not_deployed');
|
|
expect(projection.targets[0]?.lkg.status).toBe('none');
|
|
expect(projection.availableActions).toContain('deploy');
|
|
});
|
|
|
|
it('keeps a stale deployment deploy-pending instead of synced and healthy', () => {
|
|
const store = GitOpsStore.getInstance();
|
|
const tx = GitOpsTransitions.getInstance();
|
|
tx.activateDirect({ application: app('app-stale-deploy', 'stale-deploy-web'), nodeId: 1, envelope: env('op-stale') });
|
|
store.insertGeneration(gen('gen-a-stale', 'app-stale-deploy'));
|
|
store.insertGeneration(gen('gen-b-stale', 'app-stale-deploy'));
|
|
// Generation A is deployed and healthy; generation B is applied and
|
|
// desired, with automatic deployment off so nothing moves it.
|
|
const target = {
|
|
...emptyTargetRow('app-stale-deploy', 1, 1),
|
|
desired_generation_id: 'gen-b-stale',
|
|
applied_generation_id: 'gen-b-stale',
|
|
deployed_generation_id: 'gen-a-stale',
|
|
healthy_generation_id: 'gen-a-stale',
|
|
};
|
|
store.upsertTarget(target);
|
|
|
|
let projection = projectApplication('app-stale-deploy', false);
|
|
if (projection.targetMode === 'not_applicable') throw new Error('expected application');
|
|
expect(projection.targets[0]?.runtime.status).toBe('applied_not_deployed');
|
|
expect(projection.targets[0]?.health.status).toBe('pending');
|
|
expect(projection.availableActions).toContain('deploy');
|
|
// The mismatch is a confirmed drift item, not only a facet status: the
|
|
// canonical drift list must not contradict what the runtime facet says.
|
|
expect(projection.drift).toHaveLength(1);
|
|
expect(projection.drift[0]).toEqual({
|
|
class: 'runtime',
|
|
expected: { kind: 'generation', id: 'gen-b-stale' },
|
|
observed: { kind: 'generation', id: 'gen-a-stale' },
|
|
freshnessAt: null,
|
|
owner: 'ComposeService',
|
|
reason: 'the target is running a different generation than the one it was asked to run',
|
|
configuredPolicy: null,
|
|
affectedTargets: [{ nodeId: 1, stackName: 'stale-deploy-web' }],
|
|
action: 'deploy',
|
|
});
|
|
|
|
// Re-derived from the store rows rather than any carried-over state, so a
|
|
// restart reads the same answer, item included.
|
|
expect(GitOpsStore.getInstance().getTarget('app-stale-deploy', 1)?.deployed_generation_id).toBe('gen-a-stale');
|
|
projection = projectApplication('app-stale-deploy', false);
|
|
if (projection.targetMode === 'not_applicable') throw new Error('expected application');
|
|
expect(projection.targets[0]?.runtime.status).toBe('applied_not_deployed');
|
|
expect(projection.drift).toHaveLength(1);
|
|
|
|
// Once the deploy lands the target awaits its own health run instead of
|
|
// inheriting generation A's green verdict, and the mismatch item clears:
|
|
// desired and deployed now agree, so there is nothing left to report.
|
|
store.upsertTarget({ ...target, deployed_generation_id: 'gen-b-stale' });
|
|
projection = projectApplication('app-stale-deploy', false);
|
|
if (projection.targetMode === 'not_applicable') throw new Error('expected application');
|
|
expect(projection.targets[0]?.runtime.status).toBe('fully_deployed_health_pending');
|
|
expect(projection.targets[0]?.health.status).toBe('pending');
|
|
expect(projection.drift).toHaveLength(0);
|
|
|
|
// A passing run recorded against the desired generation answers for it
|
|
// even while a different generation is deployed. No producer reaches this
|
|
// combination today; the pin keeps any tightening of the comparison a
|
|
// conscious decision rather than an accident.
|
|
store.upsertTarget({ ...target, healthy_generation_id: 'gen-b-stale' });
|
|
projection = projectApplication('app-stale-deploy', false);
|
|
if (projection.targetMode === 'not_applicable') throw new Error('expected application');
|
|
expect(projection.targets[0]?.runtime.status).toBe('applied_not_deployed');
|
|
expect(projection.targets[0]?.health.status).toBe('passed');
|
|
});
|
|
|
|
it('keeps the generation-mismatch drift item after a failed redeploy', () => {
|
|
const store = GitOpsStore.getInstance();
|
|
const tx = GitOpsTransitions.getInstance();
|
|
tx.activateDirect({ application: app('app-fail-drift', 'fail-drift-web'), nodeId: 1, envelope: env('op-fd-act') });
|
|
store.insertGeneration(gen('gen-fd-a', 'app-fail-drift'));
|
|
store.insertGeneration(gen('gen-fd-b', 'app-fail-drift'));
|
|
// Generation A ships and binds, then B is applied as the new desired
|
|
// state while A keeps serving.
|
|
tx.fetchStarted('app-fail-drift', env('op-fd-f1'));
|
|
tx.fetched('app-fail-drift', 'abc123', env('op-fd-f1'));
|
|
tx.candidateReady('app-fail-drift', 'gen-fd-a', false, env('op-fd-c1'));
|
|
tx.applied({
|
|
applicationId: 'app-fail-drift',
|
|
generationId: 'gen-fd-a',
|
|
artifactSetId: 'art-fd-a',
|
|
sourceAcceptanceId: 'acc-fd-a',
|
|
authority: 'operator',
|
|
envelope: env('op-fd-a1'),
|
|
});
|
|
tx.deployStarted('app-fail-drift', 1, 'gen-fd-a', env('op-fd-d1'));
|
|
tx.deployBound('app-fail-drift', 1, 'gen-fd-a', env('op-fd-d1'));
|
|
tx.fetchStarted('app-fail-drift', env('op-fd-f2'));
|
|
tx.fetched('app-fail-drift', 'def456', env('op-fd-f2'));
|
|
tx.candidateReady('app-fail-drift', 'gen-fd-b', false, env('op-fd-c2'));
|
|
tx.applied({
|
|
applicationId: 'app-fail-drift',
|
|
generationId: 'gen-fd-b',
|
|
artifactSetId: 'art-fd-b',
|
|
sourceAcceptanceId: 'acc-fd-b',
|
|
authority: 'operator',
|
|
envelope: env('op-fd-a2'),
|
|
});
|
|
|
|
// Sanity: the clean mismatch reports one item offering deploy.
|
|
let projection = projectApplication('app-fail-drift', false);
|
|
if (projection.targetMode === 'not_applicable') throw new Error('expected application');
|
|
expect(projection.targets[0]?.runtime.status).toBe('applied_not_deployed');
|
|
expect(projection.drift).toHaveLength(1);
|
|
expect(projection.drift[0].action).toBe('deploy');
|
|
|
|
// Mid-deploy the divergence is factual while nothing can offer deploying
|
|
// again: one item, action none, gone the moment B binds.
|
|
tx.deployStarted('app-fail-drift', 1, 'gen-fd-b', env('op-fd-d2'));
|
|
projection = projectApplication('app-fail-drift', false);
|
|
if (projection.targetMode === 'not_applicable') throw new Error('expected application');
|
|
expect(projection.targets[0]?.runtime.status).toBe('deploying');
|
|
expect(projection.drift).toHaveLength(1);
|
|
expect(projection.drift[0].action).toBe('none');
|
|
|
|
// The deploy of B fails before mutating anything; A keeps serving and the
|
|
// deployed pointer stays on it. The runtime facet now shows the failure,
|
|
// but the mismatch between what was asked for and what is running did not
|
|
// go anywhere, so the drift item must survive the presentation change.
|
|
tx.deployStarted('app-fail-drift', 1, 'gen-fd-b', env('op-fd-d2'));
|
|
tx.deployFailed('app-fail-drift', 1, 'pre_mutation', env('op-fd-d2'));
|
|
projection = projectApplication('app-fail-drift', false);
|
|
if (projection.targetMode === 'not_applicable') throw new Error('expected application');
|
|
expect(store.getTarget('app-fail-drift', 1)?.deployed_generation_id).toBe('gen-fd-a');
|
|
expect(projection.targets[0]?.runtime.status).toBe('failed_previous_workload_intact');
|
|
// No target reads applied_not_deployed here, so availableActions withholds
|
|
// deploy and the item must agree instead of advertising an absent action.
|
|
expect(projection.availableActions).not.toContain('deploy');
|
|
expect(projection.drift).toHaveLength(1);
|
|
expect(projection.drift[0]).toEqual({
|
|
class: 'runtime',
|
|
expected: { kind: 'generation', id: 'gen-fd-b' },
|
|
observed: { kind: 'generation', id: 'gen-fd-a' },
|
|
freshnessAt: null,
|
|
owner: 'ComposeService',
|
|
reason: 'the target is running a different generation than the one it was asked to run',
|
|
configuredPolicy: null,
|
|
affectedTargets: [{ nodeId: 1, stackName: 'fail-drift-web' }],
|
|
action: 'none',
|
|
});
|
|
|
|
// Re-derived from the same rows, the report is stable.
|
|
projection = projectApplication('app-fail-drift', false);
|
|
if (projection.targetMode === 'not_applicable') throw new Error('expected application');
|
|
expect(projection.drift).toHaveLength(1);
|
|
expect(projection.drift[0].expected).toEqual({ kind: 'generation', id: 'gen-fd-b' });
|
|
expect(projection.drift[0].action).toBe('none');
|
|
|
|
// The artifact observation describing the workload that is being replaced
|
|
// stays suppressed while the generation question stands.
|
|
// Version 2 because the apply already seeded an unresolved v1 row for
|
|
// this generation and the table is unique per generation and version.
|
|
store.insertArtifactSet({
|
|
id: 'art-fd-b-expected',
|
|
generation_id: 'gen-fd-b',
|
|
evidence_version: 2,
|
|
authoritative: 0,
|
|
qualification: 'exact',
|
|
evidence_json: JSON.stringify({ kind: 'exact', identity: 'sha256:wanted' }),
|
|
created_at: 1,
|
|
});
|
|
const failed = store.getTarget('app-fail-drift', 1)!;
|
|
store.upsertTarget({
|
|
...failed,
|
|
expected_artifact_set_id: 'art-fd-b-expected',
|
|
observed_artifact_identity_json: JSON.stringify({ kind: 'exact', identity: 'sha256:serving', observedAt: 7 }),
|
|
});
|
|
projection = projectApplication('app-fail-drift', false);
|
|
if (projection.targetMode === 'not_applicable') throw new Error('expected application');
|
|
expect(projection.drift).toHaveLength(1);
|
|
expect(projection.drift[0].expected.kind).toBe('generation');
|
|
|
|
// The post-mutation variant reports the same way. Even when Compose was
|
|
// handed off, the deployed pointer stays on the old generation until a
|
|
// successful bind proves the new one, so the report stays anchored to
|
|
// whatever is actually serving.
|
|
tx.deployStarted('app-fail-drift', 1, 'gen-fd-b', env('op-fd-d4'));
|
|
tx.deployFailed('app-fail-drift', 1, 'post_mutation', env('op-fd-d4'));
|
|
projection = projectApplication('app-fail-drift', false);
|
|
if (projection.targetMode === 'not_applicable') throw new Error('expected application');
|
|
expect(projection.targets[0]?.runtime.status).toBe('failed_after_mutation');
|
|
expect(projection.drift).toHaveLength(1);
|
|
expect(projection.drift[0].observed).toEqual({ kind: 'generation', id: 'gen-fd-a' });
|
|
expect(projection.drift[0].action).toBe('none');
|
|
|
|
// Binding B clears the item along with the failure. The artifact probe
|
|
// from the suppression check goes with it, so the converged target is
|
|
// judged on pointers and health alone.
|
|
tx.deployStarted('app-fail-drift', 1, 'gen-fd-b', env('op-fd-d3'));
|
|
tx.deployBound('app-fail-drift', 1, 'gen-fd-b', env('op-fd-d3'));
|
|
store.upsertTarget({
|
|
...store.getTarget('app-fail-drift', 1)!,
|
|
expected_artifact_set_id: null,
|
|
observed_artifact_identity_json: null,
|
|
});
|
|
projection = projectApplication('app-fail-drift', false);
|
|
if (projection.targetMode === 'not_applicable') throw new Error('expected application');
|
|
expect(projection.targets[0]?.runtime.status).toBe('fully_deployed_health_pending');
|
|
expect(projection.drift).toHaveLength(0);
|
|
});
|
|
|
|
it('does not report generation mismatch on a retired target', () => {
|
|
const store = GitOpsStore.getInstance();
|
|
const tx = GitOpsTransitions.getInstance();
|
|
tx.activateDirect({ application: app('app-tomb-drift', 'tomb-drift-web'), nodeId: 1, envelope: env('op-td-act') });
|
|
store.insertGeneration(gen('gen-td-a', 'app-tomb-drift'));
|
|
store.insertGeneration(gen('gen-td-b', 'app-tomb-drift'));
|
|
// Retirement clears failure and LKG state but leaves the pointers alone,
|
|
// so a target retired mid-pending-deploy keeps divergent pointers. No
|
|
// transition can rebind it afterwards, so the mismatch must stay silent
|
|
// instead of becoming an item nothing could ever clear.
|
|
store.upsertTarget({
|
|
...emptyTargetRow('app-tomb-drift', 1, 1),
|
|
target_status: 'tombstoned',
|
|
desired_generation_id: 'gen-td-b',
|
|
applied_generation_id: 'gen-td-b',
|
|
deployed_generation_id: 'gen-td-a',
|
|
});
|
|
|
|
const projection = projectApplication('app-tomb-drift', false);
|
|
if (projection.targetMode === 'not_applicable') throw new Error('expected application');
|
|
expect(projection.targets[0]?.runtime.status).toBe('tombstoned');
|
|
expect(projection.drift).toHaveLength(0);
|
|
});
|
|
|
|
it('keeps a failed sibling out of another target\'s deploy action', () => {
|
|
const store = GitOpsStore.getInstance();
|
|
const tx = GitOpsTransitions.getInstance();
|
|
tx.activateDirect({ application: app('app-sib', 'sib-web'), nodeId: 1, envelope: env('op-sib') });
|
|
store.insertGeneration(gen('gen-a-sib', 'app-sib'));
|
|
store.insertGeneration(gen('gen-b-sib', 'app-sib'));
|
|
// Node 1 diverges cleanly; node 2 carries the same divergence but sits in
|
|
// a failed state. The deploy question is per target: node 2 must not be
|
|
// told to deploy because node 1 legally can.
|
|
store.upsertTarget({
|
|
...emptyTargetRow('app-sib', 1, 1),
|
|
desired_generation_id: 'gen-b-sib',
|
|
applied_generation_id: 'gen-b-sib',
|
|
deployed_generation_id: 'gen-a-sib',
|
|
healthy_generation_id: 'gen-a-sib',
|
|
});
|
|
store.upsertTarget({
|
|
...emptyTargetRow('app-sib', 2, 2),
|
|
desired_generation_id: 'gen-b-sib',
|
|
applied_generation_id: 'gen-b-sib',
|
|
deployed_generation_id: 'gen-a-sib',
|
|
failure_stage: 'deploy',
|
|
failure_class: 'pre_mutation',
|
|
});
|
|
// Node 3 carries the same divergence under an operator pause: a paused
|
|
// target cannot act, so its item stays none like the failed one.
|
|
store.upsertTarget({
|
|
...emptyTargetRow('app-sib', 3, 3),
|
|
desired_generation_id: 'gen-b-sib',
|
|
applied_generation_id: 'gen-b-sib',
|
|
deployed_generation_id: 'gen-a-sib',
|
|
pause_at: 1,
|
|
pause_reason: 'operator',
|
|
});
|
|
|
|
const projection = projectApplication('app-sib', false);
|
|
if (projection.targetMode === 'not_applicable') throw new Error('expected application');
|
|
expect(projection.availableActions).toContain('deploy');
|
|
expect(projection.drift).toHaveLength(3);
|
|
expect(projection.drift[0].affectedTargets[0]?.nodeId).toBe(1);
|
|
expect(projection.drift[0].action).toBe('deploy');
|
|
expect(projection.drift[1].affectedTargets[0]?.nodeId).toBe(2);
|
|
expect(projection.drift[1].action).toBe('none');
|
|
expect(projection.drift[2].affectedTargets[0]?.nodeId).toBe(3);
|
|
expect(projection.drift[2].action).toBe('none');
|
|
});
|
|
|
|
it('never advertises Direct deployment for a Blueprint-mode application', () => {
|
|
const store = GitOpsStore.getInstance();
|
|
store.insertGeneration(gen('gen-bp-wanted', 'app-bp-deploy'));
|
|
store.insertGeneration(gen('gen-bp-serving', 'app-bp-deploy'));
|
|
store.insertApplication(rawApp('app-bp-deploy', {
|
|
target_mode: 'inline_blueprint',
|
|
blueprint_id: 9,
|
|
lifecycle_key: 'blueprint:9',
|
|
stack_name: null,
|
|
configured_repo_url: null,
|
|
repo_identity_json: null,
|
|
configured_ref: null,
|
|
}));
|
|
// A divergent Blueprint target reads applied_not_deployed like any other,
|
|
// but Direct deployment is not a legal move for this mode: only an
|
|
// identity-matched interruption retry ever deploys here.
|
|
store.upsertTarget({
|
|
...emptyTargetRow('app-bp-deploy', 1, 1),
|
|
desired_generation_id: 'gen-bp-wanted',
|
|
applied_generation_id: 'gen-bp-wanted',
|
|
deployed_generation_id: 'gen-bp-serving',
|
|
});
|
|
|
|
const projection = projectApplication('app-bp-deploy', false);
|
|
if (projection.targetMode === 'not_applicable') throw new Error('expected application');
|
|
expect(projection.availableActions).not.toContain('deploy');
|
|
expect(projection.drift).toHaveLength(1);
|
|
expect(projection.drift[0].action).toBe('none');
|
|
});
|
|
|
|
it('retries an interrupted Blueprint deploy only while the recorded identities still match', () => {
|
|
const store = GitOpsStore.getInstance();
|
|
const bpApp = (id: string, blueprintId: number, overrides: Partial<GitOpsApplicationRow>) =>
|
|
rawApp(id, {
|
|
target_mode: 'inline_blueprint',
|
|
blueprint_id: blueprintId,
|
|
lifecycle_key: `blueprint:${blueprintId}`,
|
|
stack_name: null,
|
|
configured_repo_url: null,
|
|
repo_identity_json: null,
|
|
configured_ref: null,
|
|
...overrides,
|
|
});
|
|
const divergentTarget = (appId: string) => ({
|
|
...emptyTargetRow(appId, 1, 1),
|
|
desired_generation_id: `wanted-${appId}`,
|
|
applied_generation_id: `wanted-${appId}`,
|
|
deployed_generation_id: `serving-${appId}`,
|
|
interruption_stage: 'blueprint_deploy_started' as const,
|
|
interruption_at: 1,
|
|
});
|
|
|
|
// Inline reality today: no rollout candidate producer has run, so the
|
|
// application and the recorded crash carry no candidate id at all. The
|
|
// absent pair matches, leaving the intent revision as the live identity
|
|
// that decides the retry.
|
|
store.insertGeneration(gen('wanted-app-bp-r-vac', 'app-bp-r-vac'));
|
|
store.insertGeneration(gen('serving-app-bp-r-vac', 'app-bp-r-vac'));
|
|
store.insertApplication(bpApp('app-bp-r-vac', 12, { intent_revision_id: 'ir-v' }));
|
|
store.upsertTarget({
|
|
...divergentTarget('app-bp-r-vac'),
|
|
interruption_intent_revision_id: 'ir-v',
|
|
});
|
|
|
|
let projection = projectApplication('app-bp-r-vac', false);
|
|
if (projection.targetMode === 'not_applicable') throw new Error('expected application');
|
|
const vacRuntime = projection.targets[0]?.runtime;
|
|
if (!vacRuntime || vacRuntime.status !== 'completion_unknown') throw new Error('expected completion_unknown');
|
|
expect(vacRuntime.interruptedStage).toBe('blueprint_deploy_started');
|
|
expect(projection.availableActions).toContain('deploy');
|
|
|
|
// With a candidate in play, both recorded identities must equal what the
|
|
// application requires for the repeat to stay legal.
|
|
store.insertGeneration(gen('wanted-app-bp-r-match', 'app-bp-r-match'));
|
|
store.insertGeneration(gen('serving-app-bp-r-match', 'app-bp-r-match'));
|
|
store.insertApplication(bpApp('app-bp-r-match', 13, { intent_revision_id: 'ir-m', rollout_candidate_id: 'rc-m' }));
|
|
store.upsertTarget({
|
|
...divergentTarget('app-bp-r-match'),
|
|
interruption_intent_revision_id: 'ir-m',
|
|
interruption_rollout_candidate_id: 'rc-m',
|
|
});
|
|
projection = projectApplication('app-bp-r-match', false);
|
|
if (projection.targetMode === 'not_applicable') throw new Error('expected application');
|
|
expect(projection.availableActions).toContain('deploy');
|
|
|
|
// Candidate-only mismatch: the intent still matches but the recorded
|
|
// rollout candidate was superseded. Both persisted identities are
|
|
// contractually significant, so either one drifting alone suppresses
|
|
// the retry.
|
|
store.upsertTarget({
|
|
...store.getTarget('app-bp-r-match', 1)!,
|
|
interruption_rollout_candidate_id: 'rc-superseded',
|
|
});
|
|
projection = projectApplication('app-bp-r-match', false);
|
|
if (projection.targetMode === 'not_applicable') throw new Error('expected application');
|
|
expect(projection.availableActions).not.toContain('deploy');
|
|
expect(projection.drift[0]?.action).toBe('none');
|
|
|
|
// A superseded intent revision means the recorded operation names an
|
|
// identity nobody requires anymore, so the retry disappears even though
|
|
// the divergence itself still reports.
|
|
store.upsertTarget({
|
|
...store.getTarget('app-bp-r-match', 1)!,
|
|
interruption_intent_revision_id: 'ir-superseded',
|
|
interruption_rollout_candidate_id: 'rc-m',
|
|
});
|
|
projection = projectApplication('app-bp-r-match', false);
|
|
if (projection.targetMode === 'not_applicable') throw new Error('expected application');
|
|
expect(projection.targets[0]?.runtime.status).toBe('completion_unknown');
|
|
expect(projection.availableActions).not.toContain('deploy');
|
|
expect(projection.drift).toHaveLength(1);
|
|
expect(projection.drift[0].action).toBe('none');
|
|
});
|
|
|
|
it('retries an interrupted Direct deploy only while the interrupted generation still matches', () => {
|
|
const store = GitOpsStore.getInstance();
|
|
const tx = GitOpsTransitions.getInstance();
|
|
tx.activateDirect({ application: app('app-int-dep', 'int-dep-web'), nodeId: 1, envelope: env('op-id-act') });
|
|
store.insertGeneration(gen('gen-a-id', 'app-int-dep'));
|
|
// Cycle B fetches a different commit; carrying that sha keeps the fixture
|
|
// in the accepted state instead of a reconcile-required one.
|
|
store.insertGeneration({ ...gen('gen-b-id', 'app-int-dep'), commit_sha: 'def456' });
|
|
store.insertGeneration(gen('gen-c-id', 'app-int-dep'));
|
|
tx.fetchStarted('app-int-dep', env('op-id-f1'));
|
|
tx.fetched('app-int-dep', 'abc123', env('op-id-f1'));
|
|
tx.candidateReady('app-int-dep', 'gen-a-id', false, env('op-id-c1'));
|
|
tx.applied({
|
|
applicationId: 'app-int-dep',
|
|
generationId: 'gen-a-id',
|
|
artifactSetId: 'art-id-a',
|
|
sourceAcceptanceId: 'acc-id-a',
|
|
authority: 'operator',
|
|
envelope: env('op-id-a1'),
|
|
});
|
|
tx.deployStarted('app-int-dep', 1, 'gen-a-id', env('op-id-d1'));
|
|
tx.deployBound('app-int-dep', 1, 'gen-a-id', env('op-id-d1'));
|
|
tx.fetchStarted('app-int-dep', env('op-id-f2'));
|
|
tx.fetched('app-int-dep', 'def456', env('op-id-f2'));
|
|
tx.candidateReady('app-int-dep', 'gen-b-id', false, env('op-id-c2'));
|
|
tx.applied({
|
|
applicationId: 'app-int-dep',
|
|
generationId: 'gen-b-id',
|
|
artifactSetId: 'art-id-b',
|
|
sourceAcceptanceId: 'acc-id-b',
|
|
authority: 'operator',
|
|
envelope: env('op-id-a2'),
|
|
});
|
|
// Crash mid-deploy: the interruption records the generation that was
|
|
// being deployed, and a retry is legal while that still matches what the
|
|
// target wants applied.
|
|
tx.deployStarted('app-int-dep', 1, 'gen-b-id', env('op-id-d2'));
|
|
tx.interruptActiveOperations('app-int-dep', env('op-id-x'));
|
|
|
|
let projection = projectApplication('app-int-dep', false);
|
|
if (projection.targetMode === 'not_applicable') throw new Error('expected application');
|
|
const runtime = projection.targets[0]?.runtime;
|
|
if (!runtime || runtime.status !== 'completion_unknown') throw new Error('expected completion_unknown');
|
|
expect(runtime.interruptedStage).toBe('deploy_started');
|
|
expect(projection.availableActions).toContain('deploy');
|
|
|
|
// Once the target's applied and desired identities move on, the recorded
|
|
// interruption names a generation nobody wants anymore, so the retry
|
|
// disappears even though the divergence itself still reports.
|
|
const interrupted = store.getTarget('app-int-dep', 1)!;
|
|
store.upsertTarget({
|
|
...interrupted,
|
|
desired_generation_id: 'gen-c-id',
|
|
applied_generation_id: 'gen-c-id',
|
|
// The old generation's artifact expectations cannot follow the new
|
|
// desired id; clearing them keeps the interruption the only reported
|
|
// divergence here.
|
|
expected_artifact_set_id: null,
|
|
latest_artifact_set_id: null,
|
|
});
|
|
projection = projectApplication('app-int-dep', false);
|
|
if (projection.targetMode === 'not_applicable') throw new Error('expected application');
|
|
expect(projection.targets[0]?.runtime.status).toBe('completion_unknown');
|
|
expect(projection.drift).toHaveLength(1);
|
|
expect(projection.availableActions).not.toContain('deploy');
|
|
expect(projection.drift[0].action).toBe('none');
|
|
});
|
|
|
|
it('offers apply after an interrupted apply only when every apply precondition holds', () => {
|
|
const store = GitOpsStore.getInstance();
|
|
const tx = GitOpsTransitions.getInstance();
|
|
const interruptedApp = (id: string, overrides: Partial<GitOpsApplicationRow> = {}) =>
|
|
rawApp(id, {
|
|
stack_name: `${id}-web`,
|
|
interruption_stage: 'apply_started',
|
|
interruption_at: 1,
|
|
interruption_operation_id: `op-${id}`,
|
|
interruption_generation_id: `gen-${id}`,
|
|
candidate_generation_id: `gen-${id}`,
|
|
...overrides,
|
|
});
|
|
|
|
// Transition-legal positive: the recorded generation exists under this
|
|
// application with an unchanged materialization fingerprint, remains the
|
|
// current candidate, and neither suspension nor blockage intervenes, so
|
|
// finishing the apply is exactly what applyStarted would accept.
|
|
store.insertGeneration(gen('gen-app-int-ap-match', 'app-int-ap-match'));
|
|
store.insertApplication(interruptedApp('app-int-ap-match'));
|
|
let projection = projectApplication('app-int-ap-match', false);
|
|
if (projection.targetMode === 'not_applicable') throw new Error('expected application');
|
|
if (projection.facets.source.status !== 'source_unknown') throw new Error('expected source_unknown');
|
|
expect(projection.facets.source.interruptedStage).toBe('apply_started');
|
|
expect(projection.availableActions).toContain('apply');
|
|
// The recommendation is only as good as the transition it names, so the
|
|
// projected action is executed rather than trusted: this must not throw.
|
|
tx.applyStarted('app-int-ap-match', 'gen-app-int-ap-match', env('op-ap-resume'));
|
|
|
|
// Missing row: the recorded generation is gone, so applyStarted would
|
|
// refuse and the recommendation must fail closed.
|
|
store.insertApplication(interruptedApp('app-int-ap-missing'));
|
|
projection = projectApplication('app-int-ap-missing', false);
|
|
if (projection.targetMode === 'not_applicable') throw new Error('expected application');
|
|
expect(projection.availableActions).not.toContain('apply');
|
|
|
|
// Foreign owner: the candidate row exists but belongs to another
|
|
// application, which applyStarted refuses just the same.
|
|
store.insertGeneration(gen('gen-app-int-ap-foreign', 'app-not-the-owner'));
|
|
store.insertApplication(interruptedApp('app-int-ap-foreign'));
|
|
projection = projectApplication('app-int-ap-foreign', false);
|
|
if (projection.targetMode === 'not_applicable') throw new Error('expected application');
|
|
expect(projection.availableActions).not.toContain('apply');
|
|
|
|
// Fingerprint mismatch: finishing the recorded apply would use bytes built
|
|
// from different configuration. Defensive today, since shipped producers
|
|
// clear the candidate when configuration changes; the gate mirrors the
|
|
// transition's refusal either way.
|
|
store.insertGeneration({
|
|
...gen('gen-app-int-ap-fp', 'app-int-ap-fp'),
|
|
materialization_fingerprint: 'b'.repeat(64),
|
|
});
|
|
store.insertApplication(interruptedApp('app-int-ap-fp'));
|
|
projection = projectApplication('app-int-ap-fp', false);
|
|
if (projection.targetMode === 'not_applicable') throw new Error('expected application');
|
|
expect(projection.availableActions).not.toContain('apply');
|
|
|
|
// Suspended: identities line up, but the source was suspended after the
|
|
// crash and applyStarted refuses suspended sources outright.
|
|
store.insertGeneration(gen('gen-app-int-ap-susp', 'app-int-ap-susp'));
|
|
store.insertApplication(interruptedApp('app-int-ap-susp', { suspended_at: 1 }));
|
|
projection = projectApplication('app-int-ap-susp', false);
|
|
if (projection.targetMode === 'not_applicable') throw new Error('expected application');
|
|
expect(projection.availableActions).not.toContain('apply');
|
|
|
|
// Stale: the candidate moved on after the crash, so the recorded apply
|
|
// can no longer prove what it was applying and apply must not be offered.
|
|
store.insertGeneration(gen('gen-ap-new', 'app-int-ap-stale'));
|
|
store.insertApplication(interruptedApp('app-int-ap-stale', {
|
|
interruption_generation_id: 'gen-ap-old',
|
|
candidate_generation_id: 'gen-ap-new',
|
|
}));
|
|
projection = projectApplication('app-int-ap-stale', false);
|
|
if (projection.targetMode === 'not_applicable') throw new Error('expected application');
|
|
expect(projection.facets.source.status).toBe('source_unknown');
|
|
expect(projection.availableActions).not.toContain('apply');
|
|
|
|
// Blocked: the recorded apply still names the current candidate, but a
|
|
// later classification blocked that candidate, so finishing it is refused
|
|
// even though every identity still lines up.
|
|
store.insertGeneration(gen('gen-ap-b', 'app-int-ap-block'));
|
|
store.insertApplication(interruptedApp('app-int-ap-block', { candidate_plan_blocked: 1 }));
|
|
projection = projectApplication('app-int-ap-block', false);
|
|
if (projection.targetMode === 'not_applicable') throw new Error('expected application');
|
|
expect(projection.availableActions).toEqual(['dismiss']);
|
|
});
|
|
|
|
it('offers ordinary apply only when the candidate generation is present, owned, and current', () => {
|
|
const store = GitOpsStore.getInstance();
|
|
// Valid: an owned, fingerprint-matched candidate reads ready and offers
|
|
// apply exactly as the transition would accept it.
|
|
store.insertGeneration(gen('gen-cr-valid', 'app-cr-valid'));
|
|
store.insertApplication(rawApp('app-cr-valid', { stack_name: 'cr-valid-web', candidate_generation_id: 'gen-cr-valid' }));
|
|
let projection = projectApplication('app-cr-valid', false);
|
|
if (projection.targetMode === 'not_applicable') throw new Error('expected application');
|
|
expect(projection.facets.source.status).toBe('candidate_ready');
|
|
expect(projection.availableActions).toContain('apply');
|
|
|
|
// Missing: the candidate names a generation that does not exist, so
|
|
// ready would recommend an apply the transition refuses; the source must
|
|
// fail closed to reconcile-required instead, naming what was lost, and
|
|
// fetch stays on offer as the way out.
|
|
store.insertApplication(rawApp('app-cr-missing', { stack_name: 'cr-missing-web', candidate_generation_id: 'gen-cr-gone' }));
|
|
projection = projectApplication('app-cr-missing', false);
|
|
if (projection.targetMode === 'not_applicable') throw new Error('expected application');
|
|
expect(projection.facets.source.status).toBe('source_reconcile_required');
|
|
expect(projection.limitations.map((item) => item.code)).toContain('candidate_generation_invalid');
|
|
expect(projection.limitations.some((item) => item.evidence === 'gen-cr-gone')).toBe(true);
|
|
expect(projection.availableActions).not.toContain('apply');
|
|
expect(projection.availableActions).toContain('fetch');
|
|
|
|
// Foreign: the candidate row exists but belongs to another application,
|
|
// which applyStarted refuses just as surely as a missing one.
|
|
store.insertGeneration(gen('gen-cr-foreign', 'app-not-the-owner'));
|
|
store.insertApplication(rawApp('app-cr-foreign', { stack_name: 'cr-foreign-web', candidate_generation_id: 'gen-cr-foreign' }));
|
|
projection = projectApplication('app-cr-foreign', false);
|
|
if (projection.targetMode === 'not_applicable') throw new Error('expected application');
|
|
expect(projection.facets.source.status).toBe('source_reconcile_required');
|
|
expect(projection.availableActions).not.toContain('apply');
|
|
|
|
// Fingerprint mismatch: the generation's recorded fingerprint no longer
|
|
// equals the application's current one. No shipped producer leaves a
|
|
// candidate pointer across a configuration change today, so this pins
|
|
// the derivation's fail-safe side of that refusal.
|
|
store.insertGeneration({
|
|
...gen('gen-cr-stalefp', 'app-cr-fp'),
|
|
materialization_fingerprint: 'b'.repeat(64),
|
|
});
|
|
store.insertApplication(rawApp('app-cr-fp', { stack_name: 'cr-fp-web', candidate_generation_id: 'gen-cr-stalefp' }));
|
|
projection = projectApplication('app-cr-fp', false);
|
|
if (projection.targetMode === 'not_applicable') throw new Error('expected application');
|
|
expect(projection.facets.source.status).toBe('source_reconcile_required');
|
|
expect(projection.availableActions).not.toContain('apply');
|
|
});
|
|
|
|
it('reports an accepted generation only when its evidence is present, owned, and current', () => {
|
|
const store = GitOpsStore.getInstance();
|
|
const acceptedApp = (id: string, overrides: Partial<GitOpsApplicationRow> = {}) =>
|
|
rawApp(id, {
|
|
stack_name: `${id}-web`,
|
|
accepted_generation_id: `gen-${id}`,
|
|
desired_commit_sha: 'abc123',
|
|
...overrides,
|
|
});
|
|
|
|
// Valid: the accepted row exists under this application with the
|
|
// materialization fingerprint it was built from and the commit the
|
|
// configuration asks for, so success is the honest answer.
|
|
store.insertGeneration(gen('gen-app-acc-valid', 'app-acc-valid'));
|
|
store.insertApplication(acceptedApp('app-acc-valid'));
|
|
let projection = projectApplication('app-acc-valid', false);
|
|
if (projection.targetMode === 'not_applicable') throw new Error('expected application');
|
|
expect(projection.facets.source.status).toBe('application_generation_accepted');
|
|
expect(projection.availableActions).toEqual(['none']);
|
|
|
|
// Missing: the accepted pointer names a generation that is gone, so
|
|
// neither the fingerprint nor the sha comparison can run and success
|
|
// would be claimed without any evidence behind it.
|
|
store.insertApplication(acceptedApp('app-acc-missing'));
|
|
projection = projectApplication('app-acc-missing', false);
|
|
if (projection.targetMode === 'not_applicable') throw new Error('expected application');
|
|
expect(projection.facets.source.status).toBe('source_reconcile_required');
|
|
expect(projection.limitations.map((item) => item.code)).toContain('accepted_generation_invalid');
|
|
expect(projection.limitations.some((item) => item.evidence === 'gen-app-acc-missing')).toBe(true);
|
|
expect(projection.availableActions).toContain('fetch');
|
|
|
|
// Foreign: the row exists but belongs to another application, which is
|
|
// the same refusal with the same recovery path.
|
|
store.insertGeneration(gen('gen-app-acc-foreign', 'app-not-the-owner'));
|
|
store.insertApplication(acceptedApp('app-acc-foreign'));
|
|
projection = projectApplication('app-acc-foreign', false);
|
|
if (projection.targetMode === 'not_applicable') throw new Error('expected application');
|
|
expect(projection.facets.source.status).toBe('source_reconcile_required');
|
|
expect(projection.limitations.map((item) => item.code)).toContain('accepted_generation_invalid');
|
|
expect(projection.availableActions).toContain('fetch');
|
|
|
|
// Fingerprint mismatch: the accepted row is present and owned but its
|
|
// materialization fingerprint differs from the application's current one.
|
|
store.insertGeneration({
|
|
...gen('gen-app-acc-fp', 'app-acc-fp'),
|
|
materialization_fingerprint: 'b'.repeat(64),
|
|
});
|
|
store.insertApplication(acceptedApp('app-acc-fp'));
|
|
projection = projectApplication('app-acc-fp', false);
|
|
if (projection.targetMode === 'not_applicable') throw new Error('expected application');
|
|
expect(projection.facets.source.status).toBe('source_reconcile_required');
|
|
expect(projection.availableActions).toContain('fetch');
|
|
|
|
// Sha mismatch: built from the right configuration but not the commit the
|
|
// configuration currently names.
|
|
store.insertGeneration({
|
|
...gen('gen-app-acc-sha', 'app-acc-sha'),
|
|
commit_sha: 'def456',
|
|
});
|
|
store.insertApplication(acceptedApp('app-acc-sha', { desired_commit_sha: '789abc' }));
|
|
projection = projectApplication('app-acc-sha', false);
|
|
if (projection.targetMode === 'not_applicable') throw new Error('expected application');
|
|
expect(projection.facets.source.status).toBe('source_reconcile_required');
|
|
expect(projection.availableActions).toContain('fetch');
|
|
});
|
|
|
|
it('limits fetch to live Direct applications', () => {
|
|
const store = GitOpsStore.getInstance();
|
|
const tx = GitOpsTransitions.getInstance();
|
|
// Direct control: a never-reconciled stack is offered fetch.
|
|
tx.activateDirect({ application: app('app-fetch-direct', 'fetch-direct-web'), nodeId: 1, envelope: env('op-fd') });
|
|
const direct = projectApplication('app-fetch-direct', false);
|
|
if (direct.targetMode === 'not_applicable') throw new Error('expected application');
|
|
expect(direct.availableActions).toContain('fetch');
|
|
|
|
// A Git-backed Blueprint application with the same unreconciled source
|
|
// state gets no fetch: the revision-state action rules reserve fetch for
|
|
// Direct applications, and Blueprint source integration ships later.
|
|
store.insertApplication(rawApp('app-fetch-bp', {
|
|
target_mode: 'blueprint',
|
|
blueprint_id: 21,
|
|
lifecycle_key: 'blueprint:21',
|
|
stack_name: null,
|
|
}));
|
|
const bp = projectApplication('app-fetch-bp', false);
|
|
if (bp.targetMode === 'not_applicable') throw new Error('expected application');
|
|
expect(bp.availableActions).not.toContain('fetch');
|
|
});
|
|
|
|
it('offers approve_legacy while Inline placement review is pending', () => {
|
|
const store = GitOpsStore.getInstance();
|
|
store.insertApplication(rawApp('app-bp-legacy', {
|
|
target_mode: 'inline_blueprint',
|
|
blueprint_id: 11,
|
|
lifecycle_key: 'blueprint:11',
|
|
stack_name: null,
|
|
configured_repo_url: null,
|
|
repo_identity_json: null,
|
|
configured_ref: null,
|
|
intent_revision_id: 'ir-11',
|
|
legacy_combined_approval_ref: 'legacy-combined-11',
|
|
}));
|
|
|
|
const projection = projectApplication('app-bp-legacy', false);
|
|
if (projection.targetMode === 'not_applicable') throw new Error('expected application');
|
|
expect(projection.facets.placement.status).toBe('placement_review_pending');
|
|
expect(projection.availableActions).toEqual(['approve_legacy']);
|
|
});
|
|
|
|
it('still judges a target with no desired id against its deployed pointer', () => {
|
|
const store = GitOpsStore.getInstance();
|
|
const tx = GitOpsTransitions.getInstance();
|
|
tx.activateDirect({ application: app('app-null-desired', 'null-desired-web'), nodeId: 1, envelope: env('op-null') });
|
|
store.insertGeneration(gen('gen-a-null', 'app-null-desired'));
|
|
// Recovered and legacy rows can carry pointers with no desired id. The
|
|
// deployed pointer stays their only basis to judge.
|
|
store.upsertTarget({
|
|
...emptyTargetRow('app-null-desired', 1, 1),
|
|
applied_generation_id: 'gen-a-null',
|
|
deployed_generation_id: 'gen-a-null',
|
|
healthy_generation_id: 'gen-a-null',
|
|
});
|
|
|
|
const projection = projectApplication('app-null-desired', false);
|
|
if (projection.targetMode === 'not_applicable') throw new Error('expected application');
|
|
expect(projection.targets[0]?.runtime.status).toBe('synced_and_healthy');
|
|
expect(projection.targets[0]?.health.status).toBe('passed');
|
|
});
|
|
|
|
it('emits the runtime drift item when a comparable observation disagrees', () => {
|
|
const store = GitOpsStore.getInstance();
|
|
const tx = GitOpsTransitions.getInstance();
|
|
tx.activateDirect({ application: app('app-drift-item', 'drift-item-web'), nodeId: 1, envelope: env('op-drift') });
|
|
store.insertGeneration(gen('gen-drift', 'app-drift-item'));
|
|
store.insertArtifactSet({
|
|
id: 'art-expected-drift',
|
|
generation_id: 'gen-drift',
|
|
evidence_version: 1,
|
|
authoritative: 0,
|
|
qualification: 'exact',
|
|
evidence_json: JSON.stringify({ kind: 'exact', identity: 'sha256:wanted' }),
|
|
created_at: 1,
|
|
});
|
|
store.upsertTarget({
|
|
...emptyTargetRow('app-drift-item', 1, 1),
|
|
desired_generation_id: 'gen-drift',
|
|
applied_generation_id: 'gen-drift',
|
|
deployed_generation_id: 'gen-drift',
|
|
expected_artifact_set_id: 'art-expected-drift',
|
|
observed_artifact_identity_json: JSON.stringify({ kind: 'exact', identity: 'sha256:serving', observedAt: 42 }),
|
|
});
|
|
|
|
let projection = projectApplication('app-drift-item', false);
|
|
if (projection.targetMode === 'not_applicable') throw new Error('expected application');
|
|
expect(projection.targets[0]?.runtime.status).toBe('runtime_artifact_drift');
|
|
expect(projection.drift).toHaveLength(1);
|
|
expect(projection.drift[0]).toEqual({
|
|
class: 'runtime',
|
|
expected: { kind: 'artifact_set', id: 'art-expected-drift', qualification: 'exact', evidenceVersion: 1 },
|
|
observed: { kind: 'runtime_artifact', identity: 'sha256:serving', observedAt: 42 },
|
|
freshnessAt: 42,
|
|
owner: 'observed_artifact_identity',
|
|
reason: 'the running workload reports an artifact identity other than the expected artifact set',
|
|
configuredPolicy: null,
|
|
affectedTargets: [{ nodeId: 1, stackName: 'drift-item-web' }],
|
|
action: 'none',
|
|
});
|
|
|
|
// Equal comparable identities are not drift: the item disappears and the
|
|
// chain continues to health instead of parking in verification pending.
|
|
store.upsertTarget({
|
|
...emptyTargetRow('app-drift-item', 1, 1),
|
|
desired_generation_id: 'gen-drift',
|
|
applied_generation_id: 'gen-drift',
|
|
deployed_generation_id: 'gen-drift',
|
|
expected_artifact_set_id: 'art-expected-drift',
|
|
observed_artifact_identity_json: JSON.stringify({ kind: 'qualified', identity: 'sha256:wanted', observedAt: 43 }),
|
|
});
|
|
projection = projectApplication('app-drift-item', false);
|
|
if (projection.targetMode === 'not_applicable') throw new Error('expected application');
|
|
expect(projection.drift).toHaveLength(0);
|
|
|
|
// An observation that is not comparable never becomes a confirmed item.
|
|
store.upsertTarget({
|
|
...emptyTargetRow('app-drift-item', 1, 1),
|
|
desired_generation_id: 'gen-drift',
|
|
applied_generation_id: 'gen-drift',
|
|
deployed_generation_id: 'gen-drift',
|
|
expected_artifact_set_id: 'art-expected-drift',
|
|
observed_artifact_identity_json: JSON.stringify({ kind: 'stale', identity: 'sha256:serving', observedAt: 44 }),
|
|
});
|
|
projection = projectApplication('app-drift-item', false);
|
|
if (projection.targetMode === 'not_applicable') throw new Error('expected application');
|
|
expect(projection.targets[0]?.runtime.status).toBe('artifact_verification_pending');
|
|
expect(projection.drift).toHaveLength(0);
|
|
|
|
// Ordering pin: a stale deployment outranks artifact verification. With
|
|
// the desired generation applied but an older one deployed, the deploy
|
|
// question comes first, so the mismatch item is emitted while the artifact
|
|
// observation describing the workload about to be replaced is not.
|
|
store.insertGeneration(gen('gen-b-drift', 'app-drift-item'));
|
|
store.upsertTarget({
|
|
...emptyTargetRow('app-drift-item', 1, 1),
|
|
desired_generation_id: 'gen-drift',
|
|
applied_generation_id: 'gen-drift',
|
|
deployed_generation_id: 'gen-b-drift',
|
|
healthy_generation_id: 'gen-b-drift',
|
|
expected_artifact_set_id: 'art-expected-drift',
|
|
observed_artifact_identity_json: JSON.stringify({ kind: 'exact', identity: 'sha256:serving', observedAt: 45 }),
|
|
});
|
|
projection = projectApplication('app-drift-item', false);
|
|
if (projection.targetMode === 'not_applicable') throw new Error('expected application');
|
|
expect(projection.targets[0]?.runtime.status).toBe('applied_not_deployed');
|
|
expect(projection.drift).toHaveLength(1);
|
|
expect(projection.drift[0]).toEqual({
|
|
class: 'runtime',
|
|
expected: { kind: 'generation', id: 'gen-drift' },
|
|
observed: { kind: 'generation', id: 'gen-b-drift' },
|
|
// Pointer-to-pointer comparison carries no observation timestamp.
|
|
freshnessAt: null,
|
|
owner: 'ComposeService',
|
|
reason: 'the target is running a different generation than the one it was asked to run',
|
|
configuredPolicy: null,
|
|
affectedTargets: [{ nodeId: 1, stackName: 'drift-item-web' }],
|
|
action: 'deploy',
|
|
});
|
|
|
|
// An application-level gate withholds the action without removing the
|
|
// fact: a fetch in flight makes availableActions none, so the item must
|
|
// say none too rather than contradicting the payload it travels in.
|
|
tx.fetchStarted('app-drift-item', env('op-f-drift'));
|
|
projection = projectApplication('app-drift-item', false);
|
|
if (projection.targetMode === 'not_applicable') throw new Error('expected application');
|
|
expect(projection.availableActions).toEqual(['none']);
|
|
expect(projection.drift).toHaveLength(1);
|
|
expect(projection.drift[0].action).toBe('none');
|
|
});
|
|
});
|
|
|
|
function env(operationId: string): EventEnvelope {
|
|
return { operationId, actor: 'tester', trigger: 'manual', at: 1 };
|
|
}
|
|
|
|
function app(id: string, stackName: string): GitOpsApplicationRow {
|
|
return {
|
|
id,
|
|
lifecycle_key: `direct:${stackName}`,
|
|
lifecycle_status: 'active',
|
|
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,
|
|
source_suspended_reason: null,
|
|
source_policy: 'manual',
|
|
poll_interval_secs: null,
|
|
next_poll_at: null,
|
|
attempt_seq: 0,
|
|
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,
|
|
};
|
|
}
|
|
|
|
/** Seeds application rows directly (the Direct fixture with overrides) rather than driving the transitions that would produce these modes and states. */
|
|
function rawApp(id: string, overrides: Partial<GitOpsApplicationRow>): GitOpsApplicationRow {
|
|
return { ...app(id, 'raw-fixture-stack'), ...overrides };
|
|
}
|
|
|
|
function gen(id: string, applicationId: string): GitOpsGenerationRow {
|
|
return {
|
|
id,
|
|
application_id: applicationId,
|
|
commit_sha: 'abc123',
|
|
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: 0,
|
|
candidate_dir: `generations/candidate-${id}`,
|
|
applied_dir: `generations/applied-${id}-0`,
|
|
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: '[]',
|
|
portable_manifest_json: null,
|
|
compose_inputs_json: null,
|
|
source_policy_evidence_json: null,
|
|
security_policy_evidence_json: null,
|
|
support_requirements_json: null,
|
|
compatibility_requirements_json: null,
|
|
created_at: 1,
|
|
};
|
|
}
|