Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 54 additions & 2 deletions src/deployment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1573,13 +1573,19 @@ export function validateDeploymentContractSet(
attempts.forEach((attempt) => {
requireLinkedRecord(attempt.plan, planMap, `deploymentAttempts.${attempt.id}.plan`, issues);
const plan = planMap.get(attempt.plan.id) as
| (LinkedRecord & { request: DeploymentRecordReference })
| (LinkedRecord & {
request: DeploymentRecordReference;
actions: Array<{ id: string }>;
})
| undefined;
const request = plan
? requestMap.get(plan.request.id) as
| (LinkedRecord & { environment: DeploymentRecordReference })
| undefined
: undefined;
const linkedApprovalActorKeys = new Set<string>();
let linkedApprovalCount = 0;
let linkedApprovalActorCount = 0;
attempt.approvals.forEach((
approval: {
decision: LinkedRecord;
Expand All @@ -1594,7 +1600,7 @@ export function validateDeploymentContractSet(
requireLinkedRecord(approval.decision, approvalMap, approvalPath, issues);
const linkedApproval = approvalMap.get(approval.decision.id) as
| (LinkedRecord & {
decision: { status: string };
decision: { status: string; actor?: ActorPointer };
scope: string;
actionId: string | null;
phaseId: string | null;
Expand All @@ -1607,6 +1613,17 @@ export function validateDeploymentContractSet(
if (!linkedApproval) {
return;
}
linkedApprovalCount += 1;
if (linkedApproval.decision.actor) {
linkedApprovalActorCount += 1;
linkedApprovalActorKeys.add(
`${linkedApproval.decision.actor.kind}:${linkedApproval.decision.actor.id}`,
);
} else {
issues.push(
`${approvalPath}.decision: linked approval decision is missing an actor`,
);
}
if (linkedApproval.decision.status !== "allowed") {
issues.push(
`${approvalPath}.decision: linked approval decision is not allowed`,
Expand Down Expand Up @@ -1650,6 +1667,41 @@ export function validateDeploymentContractSet(
);
}
});
if (
linkedApprovalCount === attempt.approvals.length
&& linkedApprovalActorCount === attempt.approvals.length
) {
const attemptDecisionActorKeys = new Set<string>(
attempt.decisionActors.map(
(actor: ActorPointer) => `${actor.kind}:${actor.id}`,
),
);
if (
attemptDecisionActorKeys.size !== linkedApprovalActorKeys.size
|| [...attemptDecisionActorKeys].some(
(actorKey) => !linkedApprovalActorKeys.has(actorKey),
)
) {
issues.push(
`deploymentAttempts.${attempt.id}.decisionActors: decision actors do not match linked approval actors`,
);
}
}
if (plan) {
const planActionIds = new Set(
plan.actions.map((action) => action.id),
);
attempt.actionSteps.forEach((
step: { actionId: string },
index: number,
) => {
if (!planActionIds.has(step.actionId)) {
issues.push(
`deploymentAttempts.${attempt.id}.actionSteps.${index}.actionId: action is not present in linked deployment plan ${plan.id}`,
);
}
});
}
if (
attempt.state === "succeeded"
&& attempt.actionSteps.some(
Expand Down
74 changes: 74 additions & 0 deletions tests/deployment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -593,6 +593,59 @@ describe("deployment contract records", () => {
expect(result.issues).toContain(expected(fixtures));
});

test("attempt decision actors must match the linked approval actors", () => {
const fixtures = createDeploymentFixtureSet();
const contractSet = deploymentFixtureSetToContractSet(fixtures);
const attemptDraft = clone(fixtures.deploymentAttempt);
attemptDraft.decisionActors = [{
kind: "agent",
id: "unrelated-decision-actor",
name: "Unrelated Decision Actor",
}];
const downstream = recomputeAttemptDownstream(fixtures, attemptDraft);

contractSet.deploymentAttempts = [downstream.deploymentAttempt];
contractSet.providerReceipts = [downstream.providerReceipt];
contractSet.deploymentReceipts = [downstream.deploymentReceipt];
contractSet.launchEvidence = [downstream.launchEvidence];

const result = validateDeploymentContractSet(runtimeSchemas, contractSet);
expect(result).toEqual({
success: false,
issues: [
`deploymentAttempts.${downstream.deploymentAttempt.id}.decisionActors: decision actors do not match linked approval actors`,
],
});
});

test("linked approval decisions without actors fail closed", () => {
const fixtures = createDeploymentFixtureSet();
const contractSet = deploymentFixtureSetToContractSet(fixtures);
const approvalDraft = clone(fixtures.deploymentApprovalDecision);
delete approvalDraft.decision.actor;
const downstream = recomputePlanDownstream(
fixtures,
fixtures.deploymentPlan,
approvalDraft,
);

contractSet.deploymentApprovalDecisions = [
downstream.deploymentApprovalDecision,
];
contractSet.deploymentAttempts = [downstream.deploymentAttempt];
contractSet.providerReceipts = [downstream.providerReceipt];
contractSet.deploymentReceipts = [downstream.deploymentReceipt];
contractSet.launchEvidence = [downstream.launchEvidence];

const result = validateDeploymentContractSet(runtimeSchemas, contractSet);
expect(result).toEqual({
success: false,
issues: [
`deploymentAttempts.${downstream.deploymentAttempt.id}.approvals.0.decision: linked approval decision is missing an actor`,
],
});
});

test("action inputs must resolve to a linked deployment record", () => {
const fixtures = createDeploymentFixtureSet();
const contractSet = deploymentFixtureSetToContractSet(fixtures);
Expand Down Expand Up @@ -620,6 +673,27 @@ describe("deployment contract records", () => {
);
});

test("attempt action steps must exist in the linked deployment plan", () => {
const fixtures = createDeploymentFixtureSet();
const contractSet = deploymentFixtureSetToContractSet(fixtures);
const attemptDraft = clone(fixtures.deploymentAttempt);
attemptDraft.actionSteps[0]!.actionId = "unapproved-action";
const downstream = recomputeAttemptDownstream(fixtures, attemptDraft);

contractSet.deploymentAttempts = [downstream.deploymentAttempt];
contractSet.providerReceipts = [downstream.providerReceipt];
contractSet.deploymentReceipts = [downstream.deploymentReceipt];
contractSet.launchEvidence = [downstream.launchEvidence];

const result = validateDeploymentContractSet(runtimeSchemas, contractSet);
expect(result).toEqual({
success: false,
issues: [
`deploymentAttempts.${downstream.deploymentAttempt.id}.actionSteps.0.actionId: action is not present in linked deployment plan ${fixtures.deploymentPlan.id}`,
],
});
});

test("a succeeded attempt cannot contain a failed action step", () => {
const fixtures = createDeploymentFixtureSet();
const contractSet = deploymentFixtureSetToContractSet(fixtures);
Expand Down
Loading