mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-10 09:26:08 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user