fix(gitops): persist source policy when upserting an existing source

PUT accepted source_policy and the legacy boolean but persisted them only
through the create path, so an operator editing an already-linked source
could not leave or return to the automatic policy; the field resolved and
then vanished. The existing-app branch of the upsert transaction now
records a source_policy_changed transition when the resolved policy
differs from the stored one. A boolean 0 or omitted still resolves to the
existing policy, so the no-silent-conversion rule is unchanged.
This commit is contained in:
Anso
2026-09-09 17:20:17 -04:00
parent 8ea2959be2
commit 0e889e0084
4 changed files with 102 additions and 0 deletions
@@ -2412,6 +2412,80 @@ describe('git-source policy compatibility', () => {
}
});
it('PUT on an existing source with explicit source_policy persists the resolved policy', async () => {
const stackName = 'policy-put-existing-review';
seedStackDir(stackName);
seedGitSource(stackName);
seedApp(stackName, 'automatic');
const fetchFromGit = stubFetchFromGit();
try {
const res = await request(app)
.put(`/api/stacks/${stackName}/git-source`)
.set('Authorization', `Bearer ${adminToken()}`)
.send(putBody({ auto_apply_on_webhook: false, source_policy: 'review' }));
expect(res.status).toBe(200);
// The stored row carries the new policy, not just the response.
const application = GitOpsStore.getInstance().getLiveDirectApplication(stackName);
expect(application?.source_policy).toBe('review');
// The projection flips with it: review never auto-applies.
expect(res.body.auto_apply_on_webhook).toBe(false);
const after = await request(app)
.get(`/api/stacks/${stackName}/git-source`)
.set('Authorization', `Bearer ${adminToken()}`);
expect(after.body.auto_apply_on_webhook).toBe(false);
} finally {
fetchFromGit.mockRestore();
deleteRows(stackName);
}
});
it('PUT on an existing source with auto_apply_on_webhook true upgrades review to automatic', async () => {
const stackName = 'policy-put-existing-automatic';
seedStackDir(stackName);
seedGitSource(stackName);
seedApp(stackName, 'review');
const fetchFromGit = stubFetchFromGit();
try {
const res = await request(app)
.put(`/api/stacks/${stackName}/git-source`)
.set('Authorization', `Bearer ${adminToken()}`)
.send(putBody({ auto_apply_on_webhook: true }));
expect(res.status).toBe(200);
const application = GitOpsStore.getInstance().getLiveDirectApplication(stackName);
expect(application?.source_policy).toBe('automatic');
expect(res.body.auto_apply_on_webhook).toBe(true);
} finally {
fetchFromGit.mockRestore();
deleteRows(stackName);
}
});
it.each([
['false', false],
['omitted', undefined],
])('PUT on an existing automatic source with %s auto_apply_on_webhook stays automatic', async (_name, flag) => {
const stackName = `policy-put-existing-keeps-auto-${_name}`;
seedStackDir(stackName);
seedGitSource(stackName);
seedApp(stackName, 'automatic');
const fetchFromGit = stubFetchFromGit();
try {
const res = await request(app)
.put(`/api/stacks/${stackName}/git-source`)
.set('Authorization', `Bearer ${adminToken()}`)
.send(putBody({ auto_apply_on_webhook: flag }));
expect(res.status).toBe(200);
// Boolean 0 or absent must not silently convert an existing
// automatic policy; the operator has to ask explicitly.
const application = GitOpsStore.getInstance().getLiveDirectApplication(stackName);
expect(application?.source_policy).toBe('automatic');
expect(res.body.auto_apply_on_webhook).toBe(true);
} finally {
fetchFromGit.mockRestore();
deleteRows(stackName);
}
});
it('explicit source_policy wins over the boolean', async () => {
const stackName = 'policy-explicit-wins';
seedStackDir(stackName);
+9
View File
@@ -983,6 +983,15 @@ export class GitSourceService {
envelope,
});
}
// A policy change on an existing application persists durably: the
// create path stamps the policy through activateDirect, and this is
// the existing-app counterpart, so explicit PUT policy edits (and
// boolean-true upgrades) survive the save. A boolean 0 or absent
// resolves to the existing policy here and never silently converts
// it.
if (app && app.source_policy !== effectivePolicy) {
GitOpsTransitions.getInstance().sourcePolicyChanged(app.id, effectivePolicy, envelope);
}
})();
if (
+1
View File
@@ -71,6 +71,7 @@ export type GitOpsHistoryStage =
| 'source_accepted'
| 'source_conflict_blocker'
| 'source_poll_scheduled'
| 'source_policy_changed'
| 'source_reconcile_started'
| 'source_reconcile_settled'
| 'source_retry_scheduled'
@@ -20,6 +20,7 @@ import type {
GitOpsIntentRevisionRow,
GitOpsRolloutCandidateRow,
GitOpsTargetCurrentRow,
SourcePolicy,
} from './types';
export type EventEnvelope = {
@@ -894,6 +895,23 @@ export class GitOpsTransitions {
});
}
/**
* Persist a resolved source policy onto a live application. The create path
* stamps the policy through activateDirect; this is the existing-app path,
* so an operator who changes the policy on an already-linked source gets a
* durable row change (and an audit line) instead of a silently discarded
* PUT field.
*/
sourcePolicyChanged(applicationId: string, sourcePolicy: SourcePolicy, envelope: EventEnvelope): TransitionResult {
return this.mutateApp(applicationId, envelope, 'source_policy_changed', 'committed', (app) => {
if (app.suspended_at) throw new GitOpsTransitionError('source is suspended');
if (app.active_operation_stage) {
throw new GitOpsTransitionError('cannot change the source policy while an operation is in flight');
}
app.source_policy = sourcePolicy;
});
}
/**
* Stop acting on a source without forgetting anything about it.
*