@casecore/release-agent 0.1.4 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -1
- package/dist/exclusive-operation.mjs +3 -0
- package/dist/index.mjs +1 -1
- package/dist/producer-admission.mjs +3 -0
- package/dist/producer-delivery.mjs +2 -2
- package/dist/producer-execution.mjs +33 -67
- package/dist/producer-journal.mjs +44 -0
- package/dist/producer-publication-failure.mjs +69 -10
- package/dist/producer-server.mjs +29 -51
- package/dist/producer-state.mjs +22 -35
- package/dist/producer.d.ts +101 -5
- package/dist/producer.mjs +45 -1
- package/dist/provenance.json +16 -14
- package/dist/publication-terminal-receipt.mjs +119 -0
- package/dist/publication-terminal.mjs +251 -0
- package/package.json +1 -1
package/dist/producer-server.mjs
CHANGED
|
@@ -2,11 +2,10 @@ import assert from 'node:assert/strict';
|
|
|
2
2
|
import { timingSafeEqual } from 'node:crypto';
|
|
3
3
|
import { createServer } from 'node:http';
|
|
4
4
|
import { configurationHook } from './configuration-hook.mjs';
|
|
5
|
+
import { verifyPublicationTerminalReceipt } from './publication-terminal-receipt.mjs';
|
|
5
6
|
|
|
6
7
|
export function producerServer(producer, token, publication = {}) {
|
|
7
|
-
assert.ok(
|
|
8
|
-
typeof token === 'string' && token.length >= 43 && !/\s/.test(token),
|
|
9
|
-
);
|
|
8
|
+
assert.ok(typeof token === 'string' && token.length >= 43 && !/\s/.test(token));
|
|
10
9
|
const expected = Buffer.from(`Bearer ${token}`);
|
|
11
10
|
const server = createServer(async (req, res) => {
|
|
12
11
|
const reply = (code, body) => {
|
|
@@ -17,31 +16,29 @@ export function producerServer(producer, token, publication = {}) {
|
|
|
17
16
|
res.end(JSON.stringify(body));
|
|
18
17
|
};
|
|
19
18
|
const supplied = Buffer.from(req.headers.authorization ?? '');
|
|
20
|
-
if (
|
|
21
|
-
supplied.length !== expected.length ||
|
|
22
|
-
!timingSafeEqual(supplied, expected)
|
|
23
|
-
)
|
|
19
|
+
if (supplied.length !== expected.length || !timingSafeEqual(supplied, expected))
|
|
24
20
|
return reply(401, { error: 'Unauthorized' });
|
|
25
21
|
try {
|
|
26
22
|
const url = new URL(req.url, 'http://localhost');
|
|
27
23
|
if (await configurationHook(req, url, producer, publication, reply)) return;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
) {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
producer.
|
|
35
|
-
);
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
);
|
|
24
|
+
const terminal = url.pathname.match(
|
|
25
|
+
/^\/v1\/release-control\/publications\/([a-f\d-]{36})\/terminal$/i
|
|
26
|
+
);
|
|
27
|
+
if (req.method === 'GET' && terminal && !url.search) {
|
|
28
|
+
const receipt = publication.readTerminalReceipt
|
|
29
|
+
? await publication.readTerminalReceipt(terminal[1])
|
|
30
|
+
: producer.journal.read().commands[terminal[1]]?.deliveryTerminalReceipt;
|
|
31
|
+
if (!receipt) return reply(404, { error: 'Terminal receipt unavailable' });
|
|
32
|
+
const verified = verifyPublicationTerminalReceipt(receipt, token);
|
|
33
|
+
assert.equal(verified.proof.bindings.commandId, terminal[1]);
|
|
34
|
+
assert.equal(verified.proof.bindings.installationKey, producer.installationKey);
|
|
35
|
+
return reply(200, verified);
|
|
36
|
+
}
|
|
37
|
+
if (req.method === 'GET' && url.pathname === '/v1/release-control/snapshot') {
|
|
38
|
+
assert.equal(url.searchParams.get('installationKey'), producer.installationKey);
|
|
39
|
+
return reply(200, await producer.snapshot(url.searchParams.get('after') ?? '0'));
|
|
40
40
|
}
|
|
41
|
-
if (
|
|
42
|
-
req.method === 'POST' &&
|
|
43
|
-
url.pathname === '/v1/release-control/commands'
|
|
44
|
-
) {
|
|
41
|
+
if (req.method === 'POST' && url.pathname === '/v1/release-control/commands') {
|
|
45
42
|
let bytes = 0;
|
|
46
43
|
const chunks = [];
|
|
47
44
|
for await (const chunk of req) {
|
|
@@ -51,37 +48,21 @@ export function producerServer(producer, token, publication = {}) {
|
|
|
51
48
|
}
|
|
52
49
|
return reply(
|
|
53
50
|
200,
|
|
54
|
-
await producer.command(
|
|
55
|
-
JSON.parse(Buffer.concat(chunks).toString('utf8')),
|
|
56
|
-
),
|
|
51
|
+
await producer.command(JSON.parse(Buffer.concat(chunks).toString('utf8')))
|
|
57
52
|
);
|
|
58
53
|
}
|
|
59
54
|
const publicationMatch = url.pathname.match(
|
|
60
|
-
/^\/v1\/release-control\/publications\/([a-f\d-]{36})(\/report)?$/i
|
|
55
|
+
/^\/v1\/release-control\/publications\/([a-f\d-]{36})(\/report)?$/i
|
|
61
56
|
);
|
|
62
57
|
if (publicationMatch && !url.search) {
|
|
63
|
-
if (
|
|
64
|
-
req.method === 'GET' &&
|
|
65
|
-
!publicationMatch[2] &&
|
|
66
|
-
publication.claimPublication
|
|
67
|
-
) {
|
|
58
|
+
if (req.method === 'GET' && !publicationMatch[2] && publication.claimPublication) {
|
|
68
59
|
const rawRun = req.headers[publication.workflowRunHeader ?? 'x-myrobotica-workflow-run'];
|
|
69
60
|
assert.ok(typeof rawRun === 'string' && /^[1-9]\d*$/.test(rawRun));
|
|
70
61
|
const workflowRunId = Number(rawRun);
|
|
71
62
|
assert.ok(Number.isSafeInteger(workflowRunId));
|
|
72
|
-
return reply(
|
|
73
|
-
200,
|
|
74
|
-
await publication.claimPublication(
|
|
75
|
-
publicationMatch[1],
|
|
76
|
-
workflowRunId,
|
|
77
|
-
),
|
|
78
|
-
);
|
|
63
|
+
return reply(200, await publication.claimPublication(publicationMatch[1], workflowRunId));
|
|
79
64
|
}
|
|
80
|
-
if (
|
|
81
|
-
req.method === 'POST' &&
|
|
82
|
-
publicationMatch[2] &&
|
|
83
|
-
publication.reportPublication
|
|
84
|
-
) {
|
|
65
|
+
if (req.method === 'POST' && publicationMatch[2] && publication.reportPublication) {
|
|
85
66
|
let bytes = 0;
|
|
86
67
|
const chunks = [];
|
|
87
68
|
for await (const chunk of req) {
|
|
@@ -93,17 +74,14 @@ export function producerServer(producer, token, publication = {}) {
|
|
|
93
74
|
200,
|
|
94
75
|
await publication.reportPublication(
|
|
95
76
|
publicationMatch[1],
|
|
96
|
-
JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
97
|
-
)
|
|
77
|
+
JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
78
|
+
)
|
|
98
79
|
);
|
|
99
80
|
}
|
|
100
81
|
}
|
|
101
|
-
const match = url.pathname.match(
|
|
102
|
-
/^\/v1\/release-control\/commands\/([a-f\d-]{36})$/i,
|
|
103
|
-
);
|
|
82
|
+
const match = url.pathname.match(/^\/v1\/release-control\/commands\/([a-f\d-]{36})$/i);
|
|
104
83
|
if (req.method === 'GET' && match) {
|
|
105
|
-
if (!producer.acknowledgement(match[1]))
|
|
106
|
-
return reply(404, { error: 'Unknown command' });
|
|
84
|
+
if (!producer.acknowledgement(match[1])) return reply(404, { error: 'Unknown command' });
|
|
107
85
|
const ack = await producer.reconcile(match[1]);
|
|
108
86
|
return reply(ack ? 200 : 404, ack ?? { error: 'Unknown command' });
|
|
109
87
|
}
|
package/dist/producer-state.mjs
CHANGED
|
@@ -3,16 +3,18 @@ import { blocker, smokeScenarios, safeStatus } from './producer-support.mjs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { createProtectedJsonStore } from './journal.mjs';
|
|
5
5
|
import { validProducerState } from './producer-journal.mjs';
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
installOperationBarrier,
|
|
8
|
+
markOperationStateLoaded,
|
|
9
|
+
reserveExclusiveOperation,
|
|
10
|
+
} from './exclusive-operation.mjs';
|
|
7
11
|
|
|
8
12
|
export class ProducerState {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
sourcePolicy,
|
|
15
|
-
}) {
|
|
13
|
+
#engineInstance;
|
|
14
|
+
static isEngineInstance(value) {
|
|
15
|
+
return value !== null && typeof value === 'object' && #engineInstance in value;
|
|
16
|
+
}
|
|
17
|
+
constructor({ installationKey, journalFile, provider, now = () => new Date(), sourcePolicy }) {
|
|
16
18
|
assert.match(installationKey, /^[\w.-]{1,200}$/);
|
|
17
19
|
assert.ok(path.isAbsolute(journalFile));
|
|
18
20
|
Object.assign(this, { installationKey, journalFile, provider, now, sourcePolicy });
|
|
@@ -26,7 +28,7 @@ export class ProducerState {
|
|
|
26
28
|
(Number.isSafeInteger(provider.snapshotTimeoutMs) &&
|
|
27
29
|
provider.snapshotTimeoutMs > 0 &&
|
|
28
30
|
provider.snapshotTimeoutMs <= 30000),
|
|
29
|
-
'Invalid fixed provider snapshot budget'
|
|
31
|
+
'Invalid fixed provider snapshot budget'
|
|
30
32
|
);
|
|
31
33
|
this.state = {
|
|
32
34
|
schema: 1,
|
|
@@ -47,9 +49,7 @@ export class ProducerState {
|
|
|
47
49
|
async initialize() {
|
|
48
50
|
this.state = this.journal.read();
|
|
49
51
|
// A restarted process must reconcile a previously admitted mutation first.
|
|
50
|
-
for (const run of this.state.runs.filter((r) =>
|
|
51
|
-
['running', 'queued'].includes(r.status),
|
|
52
|
-
)) {
|
|
52
|
+
for (const run of this.state.runs.filter((r) => ['running', 'queued'].includes(r.status))) {
|
|
53
53
|
if (
|
|
54
54
|
run.status === 'queued' &&
|
|
55
55
|
run.lane === 'scheduled' &&
|
|
@@ -62,7 +62,7 @@ export class ProducerState {
|
|
|
62
62
|
blocker(
|
|
63
63
|
'RECONCILIATION_REQUIRED',
|
|
64
64
|
'Предыдущее выполнение требует проверки фактического состояния',
|
|
65
|
-
true
|
|
65
|
+
true
|
|
66
66
|
),
|
|
67
67
|
];
|
|
68
68
|
}
|
|
@@ -108,15 +108,9 @@ export class ProducerState {
|
|
|
108
108
|
occurredAt: this.now().toISOString(),
|
|
109
109
|
code,
|
|
110
110
|
frames,
|
|
111
|
-
...(smokeScenarios.has(error?.scenario)
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
...(safeStatus(error?.expectedStatus)
|
|
115
|
-
? { expectedStatus: error.expectedStatus }
|
|
116
|
-
: {}),
|
|
117
|
-
...(safeStatus(error?.actualStatus)
|
|
118
|
-
? { actualStatus: error.actualStatus }
|
|
119
|
-
: {}),
|
|
111
|
+
...(smokeScenarios.has(error?.scenario) ? { scenario: error.scenario } : {}),
|
|
112
|
+
...(safeStatus(error?.expectedStatus) ? { expectedStatus: error.expectedStatus } : {}),
|
|
113
|
+
...(safeStatus(error?.actualStatus) ? { actualStatus: error.actualStatus } : {}),
|
|
120
114
|
...([
|
|
121
115
|
'ENOTFOUND',
|
|
122
116
|
'EAI_AGAIN',
|
|
@@ -131,9 +125,7 @@ export class ProducerState {
|
|
|
131
125
|
? { transportCode: error.transportCode }
|
|
132
126
|
: {}),
|
|
133
127
|
};
|
|
134
|
-
this.state.diagnostics = [...(this.state.diagnostics ?? []), item].slice(
|
|
135
|
-
-200,
|
|
136
|
-
);
|
|
128
|
+
this.state.diagnostics = [...(this.state.diagnostics ?? []), item].slice(-200);
|
|
137
129
|
return code;
|
|
138
130
|
}
|
|
139
131
|
async snapshot(after = '0', { deadlineAt } = {}) {
|
|
@@ -144,8 +136,8 @@ export class ProducerState {
|
|
|
144
136
|
this.provider.snapshotTimeoutMs ?? 10000,
|
|
145
137
|
deadlineAt
|
|
146
138
|
? Date.parse(deadlineAt) - this.now().getTime()
|
|
147
|
-
: (this.provider.snapshotTimeoutMs ?? 10000)
|
|
148
|
-
)
|
|
139
|
+
: (this.provider.snapshotTimeoutMs ?? 10000)
|
|
140
|
+
)
|
|
149
141
|
);
|
|
150
142
|
const controller = new AbortController();
|
|
151
143
|
let timer;
|
|
@@ -155,22 +147,17 @@ export class ProducerState {
|
|
|
155
147
|
reject(
|
|
156
148
|
Object.assign(Error('Producer status read timed out'), {
|
|
157
149
|
code: 'PRODUCER_STATUS_TIMEOUT',
|
|
158
|
-
})
|
|
150
|
+
})
|
|
159
151
|
);
|
|
160
152
|
}, limit);
|
|
161
153
|
});
|
|
162
154
|
let data;
|
|
163
155
|
try {
|
|
164
|
-
data = await Promise.race([
|
|
165
|
-
this.provider.snapshot({ signal: controller.signal }),
|
|
166
|
-
timeout,
|
|
167
|
-
]);
|
|
156
|
+
data = await Promise.race([this.provider.snapshot({ signal: controller.signal }), timeout]);
|
|
168
157
|
} finally {
|
|
169
158
|
clearTimeout(timer);
|
|
170
159
|
}
|
|
171
|
-
const candidates = new Map(
|
|
172
|
-
data.candidates.map((candidate) => [candidate.id, candidate]),
|
|
173
|
-
);
|
|
160
|
+
const candidates = new Map(data.candidates.map((candidate) => [candidate.id, candidate]));
|
|
174
161
|
for (const item of Object.values(this.state.commands)) {
|
|
175
162
|
if (
|
|
176
163
|
item.command.action !== 'publish' ||
|
package/dist/producer.d.ts
CHANGED
|
@@ -13,6 +13,56 @@ export * from './protocol.js';
|
|
|
13
13
|
export interface ProviderContext {
|
|
14
14
|
signal: AbortSignal;
|
|
15
15
|
}
|
|
16
|
+
declare const publicationTerminalBrand: unique symbol;
|
|
17
|
+
export interface PublicationTerminalAuthority {
|
|
18
|
+
readonly [publicationTerminalBrand]: true;
|
|
19
|
+
}
|
|
20
|
+
export interface PublicationTerminalBindings {
|
|
21
|
+
action: 'publish' | 'deploy';
|
|
22
|
+
commandId: string;
|
|
23
|
+
runId: string;
|
|
24
|
+
installationKey: string;
|
|
25
|
+
repository: string;
|
|
26
|
+
ref: string;
|
|
27
|
+
provider: RunnerCommand['executionProvider'];
|
|
28
|
+
candidateId: string;
|
|
29
|
+
delivery: OriginalDelivery;
|
|
30
|
+
}
|
|
31
|
+
export interface PublicationTerminalProof {
|
|
32
|
+
schema: 1;
|
|
33
|
+
bindings: PublicationTerminalBindings;
|
|
34
|
+
status: 'deployed' | 'failed';
|
|
35
|
+
completedAt: string;
|
|
36
|
+
artifactIdentity: ArtifactIdentity | null;
|
|
37
|
+
readback: ProductionReadback;
|
|
38
|
+
failure: PublicationFailureFact | null;
|
|
39
|
+
}
|
|
40
|
+
export function readPublicationTerminalAuthority(
|
|
41
|
+
authority: PublicationTerminalAuthority,
|
|
42
|
+
bindings: PublicationTerminalBindings
|
|
43
|
+
): PublicationTerminalProof;
|
|
44
|
+
export interface PublicationTerminalReceipt {
|
|
45
|
+
schema: 1;
|
|
46
|
+
kind: 'publication_terminal';
|
|
47
|
+
issuedAt: string;
|
|
48
|
+
proof: PublicationTerminalProof;
|
|
49
|
+
mac: string;
|
|
50
|
+
}
|
|
51
|
+
export function sealPublicationTerminalReceipt(
|
|
52
|
+
authority: PublicationTerminalAuthority,
|
|
53
|
+
bindings: PublicationTerminalBindings,
|
|
54
|
+
token: string
|
|
55
|
+
): Promise<PublicationTerminalReceipt>;
|
|
56
|
+
/** Authenticated data, never an execution capability. */
|
|
57
|
+
export function verifyPublicationTerminalReceipt(
|
|
58
|
+
receipt: PublicationTerminalReceipt,
|
|
59
|
+
token: string
|
|
60
|
+
): PublicationTerminalReceipt;
|
|
61
|
+
export interface TerminalVerificationContext extends ProviderContext {
|
|
62
|
+
completedAt: string;
|
|
63
|
+
status: 'deployed' | 'failed';
|
|
64
|
+
artifactIdentity?: ArtifactIdentity;
|
|
65
|
+
}
|
|
16
66
|
export interface ExecutionContext {
|
|
17
67
|
stage<T>(
|
|
18
68
|
id: StageId,
|
|
@@ -39,6 +89,13 @@ export interface ReleaseProvider {
|
|
|
39
89
|
actions: RunnerCommand['action'][];
|
|
40
90
|
snapshotTimeoutMs?: number;
|
|
41
91
|
remotePublication?: boolean;
|
|
92
|
+
publicationTerminal?(
|
|
93
|
+
command: RunnerCommand,
|
|
94
|
+
context: ProviderContext & {
|
|
95
|
+
authority: PublicationTerminalAuthority;
|
|
96
|
+
bindings: PublicationTerminalBindings;
|
|
97
|
+
}
|
|
98
|
+
): Promise<void>;
|
|
42
99
|
snapshot(context: ProviderContext): Promise<{
|
|
43
100
|
connection: ProducerSnapshot['connection'];
|
|
44
101
|
installed?: ProductionReadback | null;
|
|
@@ -56,13 +113,30 @@ export interface ReleaseProvider {
|
|
|
56
113
|
verifyPublicationArtifact?(
|
|
57
114
|
command: RunnerCommand,
|
|
58
115
|
identity: ArtifactIdentity,
|
|
59
|
-
delivery: OriginalDelivery
|
|
116
|
+
delivery: OriginalDelivery,
|
|
117
|
+
context?: TerminalVerificationContext
|
|
118
|
+
): Promise<boolean>;
|
|
119
|
+
verifyPublicationDelivery?(
|
|
120
|
+
command: RunnerCommand,
|
|
121
|
+
delivery: OriginalDelivery,
|
|
122
|
+
context?: TerminalVerificationContext
|
|
123
|
+
): Promise<boolean>;
|
|
124
|
+
verifyArtifactDelivery?(
|
|
125
|
+
command: RunnerCommand,
|
|
126
|
+
identity: ArtifactIdentity,
|
|
127
|
+
delivery: OriginalDelivery,
|
|
128
|
+
source: { repository: string; ref: string; sourceCommit: string },
|
|
129
|
+
context: TerminalVerificationContext
|
|
60
130
|
): Promise<boolean>;
|
|
61
|
-
verifyPublicationDelivery?(command: RunnerCommand, delivery: OriginalDelivery): Promise<boolean>;
|
|
62
131
|
reconcilePublicationFailure?(
|
|
63
132
|
command: RunnerCommand,
|
|
64
133
|
context: ProviderContext & { delivery: OriginalDelivery | null; recoveryDeadlineAt: number }
|
|
65
134
|
): Promise<PublicationFailureGuard | null>;
|
|
135
|
+
verifyPublicationSourceFailure?(
|
|
136
|
+
command: RunnerCommand,
|
|
137
|
+
execution: SourceRecoveryExecution,
|
|
138
|
+
context: ProviderContext & { recoveryDeadlineAt: number }
|
|
139
|
+
): Promise<boolean>;
|
|
66
140
|
validatePublicationResume?(
|
|
67
141
|
command: RunnerCommand,
|
|
68
142
|
item: Record<string, unknown>
|
|
@@ -81,13 +155,25 @@ export interface ReleaseProvider {
|
|
|
81
155
|
): Promise<Record<string, unknown>>;
|
|
82
156
|
productionFailure?(fact: ProductionReadback): Error | undefined;
|
|
83
157
|
}
|
|
84
|
-
export interface
|
|
158
|
+
export interface SourceRecoveryExecution {
|
|
159
|
+
schema: 1;
|
|
160
|
+
kind: 'source_not_pushed';
|
|
161
|
+
id: string;
|
|
162
|
+
candidateId: string;
|
|
163
|
+
sourceCommit: string;
|
|
164
|
+
requestedAt: string;
|
|
165
|
+
deadlineAt: string;
|
|
166
|
+
policy: 'immediate';
|
|
167
|
+
}
|
|
168
|
+
export function validateSourceRecoveryExecution(
|
|
169
|
+
execution: unknown,
|
|
170
|
+
command?: RunnerCommand
|
|
171
|
+
): SourceRecoveryExecution;
|
|
172
|
+
interface PublicationFailureCommon {
|
|
85
173
|
schema: 1;
|
|
86
|
-
kind: 'failed_before_installation';
|
|
87
174
|
commandId: string;
|
|
88
175
|
installationKey: string;
|
|
89
176
|
expectedSource: { repository: string; ref: string; sourceCommit: string };
|
|
90
|
-
delivery: OriginalDelivery;
|
|
91
177
|
observedAt: string;
|
|
92
178
|
stage: 'source_ready' | 'ci' | 'build' | 'verify' | 'publish';
|
|
93
179
|
authenticated: true;
|
|
@@ -96,6 +182,15 @@ export interface PublicationFailureFact {
|
|
|
96
182
|
publicationResolved: true;
|
|
97
183
|
installationNotStarted: true;
|
|
98
184
|
}
|
|
185
|
+
export type PublicationFailureFact = PublicationFailureCommon &
|
|
186
|
+
(
|
|
187
|
+
| { kind: 'failed_before_installation'; delivery: OriginalDelivery }
|
|
188
|
+
| {
|
|
189
|
+
kind: 'failed_before_source_push';
|
|
190
|
+
sourceExecution: SourceRecoveryExecution;
|
|
191
|
+
stage: 'source_ready';
|
|
192
|
+
}
|
|
193
|
+
);
|
|
99
194
|
export interface PublicationFailureGuard {
|
|
100
195
|
fact: PublicationFailureFact;
|
|
101
196
|
baselineReadback: ProductionReadback;
|
|
@@ -152,6 +247,7 @@ export function producerServer(
|
|
|
152
247
|
workflowRunHeader?: string;
|
|
153
248
|
claimPublication?(commandId: string, workflowRunId: number): Promise<unknown>;
|
|
154
249
|
reportPublication?(commandId: string, report: unknown): Promise<unknown>;
|
|
250
|
+
readTerminalReceipt?(commandId: string): Promise<PublicationTerminalReceipt | null | undefined>;
|
|
155
251
|
configure?(request: ConfigurationRequest): Promise<ConfigurationAcknowledgement | undefined>;
|
|
156
252
|
reconcileConfiguration?(
|
|
157
253
|
configurationId: string
|
package/dist/producer.mjs
CHANGED
|
@@ -3,7 +3,13 @@ import { sameIdentity, acceptedProduction } from './protocol.mjs';
|
|
|
3
3
|
import { blocker, failureMessage, failedProduction } from './producer-support.mjs';
|
|
4
4
|
import { ProducerExecution } from './producer-execution.mjs';
|
|
5
5
|
import { reconcilePublicationFailure } from './producer-publication-failure.mjs';
|
|
6
|
-
|
|
6
|
+
import { notifyPublicationTerminal } from './publication-terminal.mjs';
|
|
7
|
+
export {
|
|
8
|
+
readPublicationTerminalAuthority,
|
|
9
|
+
sealPublicationTerminalReceipt,
|
|
10
|
+
verifyPublicationTerminalReceipt,
|
|
11
|
+
} from './publication-terminal.mjs';
|
|
12
|
+
export { publicationFailureProofDigest, validateSourceRecoveryExecution } from './producer-publication-failure.mjs';
|
|
7
13
|
export {
|
|
8
14
|
sameIdentity,
|
|
9
15
|
validateExpectedSource,
|
|
@@ -18,6 +24,43 @@ export class ReleaseControlProducer extends ProducerExecution {
|
|
|
18
24
|
const item = this.state.commands[commandId];
|
|
19
25
|
assert.ok(item, 'Unknown command');
|
|
20
26
|
const run = this.state.runs.find((r) => r.commandId === commandId);
|
|
27
|
+
if (
|
|
28
|
+
['publish', 'deploy'].includes(item.command.action) &&
|
|
29
|
+
typeof this.provider.publicationTerminal === 'function'
|
|
30
|
+
) {
|
|
31
|
+
if (run?.status === 'failed' && item.publicationFailureReconciliation?.state === 'settled') {
|
|
32
|
+
await reconcilePublicationFailure(this, item, run, { terminalRetry: true });
|
|
33
|
+
return this.acknowledgement(commandId);
|
|
34
|
+
}
|
|
35
|
+
if (run?.status === 'deployed') {
|
|
36
|
+
const controller = new AbortController();
|
|
37
|
+
let timer;
|
|
38
|
+
try {
|
|
39
|
+
const fact = await Promise.race([
|
|
40
|
+
this.provider.readProduction(this.resolvedCommand(item.command), {
|
|
41
|
+
signal: controller.signal,
|
|
42
|
+
}),
|
|
43
|
+
new Promise((_, reject) => {
|
|
44
|
+
timer = setTimeout(() => {
|
|
45
|
+
controller.abort();
|
|
46
|
+
reject(Error('PUBLICATION_TERMINAL_READBACK_TIMEOUT'));
|
|
47
|
+
}, 30000);
|
|
48
|
+
}),
|
|
49
|
+
]);
|
|
50
|
+
assert.ok(
|
|
51
|
+
acceptedProduction(fact, this.resolvedCommand(item.command), this.now()),
|
|
52
|
+
'PUBLICATION_TERMINAL_READBACK_REQUIRED'
|
|
53
|
+
);
|
|
54
|
+
run.readback = structuredClone(fact);
|
|
55
|
+
await this.save();
|
|
56
|
+
await notifyPublicationTerminal(this, item, run);
|
|
57
|
+
} finally {
|
|
58
|
+
controller.abort();
|
|
59
|
+
clearTimeout(timer);
|
|
60
|
+
}
|
|
61
|
+
return this.acknowledgement(commandId);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
21
64
|
if (
|
|
22
65
|
item.command.action === 'publish' &&
|
|
23
66
|
run?.status === 'blocked' &&
|
|
@@ -140,6 +183,7 @@ export class ReleaseControlProducer extends ProducerExecution {
|
|
|
140
183
|
await this.save();
|
|
141
184
|
}
|
|
142
185
|
}
|
|
186
|
+
await notifyPublicationTerminal(this, item, run);
|
|
143
187
|
return this.acknowledgement(commandId);
|
|
144
188
|
}
|
|
145
189
|
}
|
package/dist/provenance.json
CHANGED
|
@@ -1,31 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema": 1,
|
|
3
3
|
"package": "@casecore/release-agent",
|
|
4
|
-
"version": "0.1.
|
|
5
|
-
"sourceCommit": "
|
|
4
|
+
"version": "0.1.7",
|
|
5
|
+
"sourceCommit": "517e8581d9e651cf4188c084e5a65e95b2e5bc82",
|
|
6
6
|
"sourceVerified": true,
|
|
7
7
|
"sourceDirty": false,
|
|
8
8
|
"buildKind": "release",
|
|
9
|
-
"contentDigest": "sha256:
|
|
9
|
+
"contentDigest": "sha256:bd08880215c8d1203050961fa9971925b212d463354b847d1574d92c4720c524",
|
|
10
10
|
"files": {
|
|
11
11
|
"src/configuration-hook.mjs": "sha256:1171bf982cbf7cb0092f63b633ef0b0a0947d8ed1106f88c9175c8add33d81b1",
|
|
12
|
-
"src/exclusive-operation.mjs": "sha256:
|
|
12
|
+
"src/exclusive-operation.mjs": "sha256:d5c2718a2a4228d09559e914360329383ae276f034bb468c52642a4bd6aa0d99",
|
|
13
13
|
"src/identity.mjs": "sha256:253021058fc79232d83f3ecba6f1023fd046f27aabdf9c129a103d3c9ccb07f5",
|
|
14
|
-
"src/index.mjs": "sha256:
|
|
14
|
+
"src/index.mjs": "sha256:2df871905b31d8cb287dd0700765be6dd9d76fbb91d25b9275f37b07ab4669b5",
|
|
15
15
|
"src/journal.mjs": "sha256:35a2620d56084ec1aa164e09d77b8e77096413d9572f7b601da8ef7149b996bd",
|
|
16
16
|
"src/pipeline.mjs": "sha256:7a2d39d235ce7160441d177200389c5f3eae1cc494ec36082a23e8d64a0abe3f",
|
|
17
|
-
"src/producer-admission.mjs": "sha256:
|
|
18
|
-
"src/producer-delivery.mjs": "sha256:
|
|
19
|
-
"src/producer-execution.mjs": "sha256:
|
|
20
|
-
"src/producer-journal.mjs": "sha256:
|
|
21
|
-
"src/producer-publication-failure.mjs": "sha256:
|
|
17
|
+
"src/producer-admission.mjs": "sha256:03d868141daf92351b3e1a4e70997c87fc39044624644536a374aa86d108d12d",
|
|
18
|
+
"src/producer-delivery.mjs": "sha256:04439a95afcb52d879cc83b99fa1d7716f4dff84bb7c07f88d31f534799e76a8",
|
|
19
|
+
"src/producer-execution.mjs": "sha256:e78724fa2f2fd4946ae8103bb5165b363eb968fab42add4fed572155f51871d9",
|
|
20
|
+
"src/producer-journal.mjs": "sha256:d257e2042d92b828de44247b6affbe0b06e00263a1bb1939a1faa032daeaca9e",
|
|
21
|
+
"src/producer-publication-failure.mjs": "sha256:f077e62424cffb5123a81444e872fb2e2dec9b71dec89be7ca00b37227f8a648",
|
|
22
22
|
"src/producer-publication.mjs": "sha256:c09fa07b37507093b71bbaff3af9df8ec2331c0cba24a4b936d247e9b35257f7",
|
|
23
|
-
"src/producer-server.mjs": "sha256:
|
|
24
|
-
"src/producer-state.mjs": "sha256:
|
|
23
|
+
"src/producer-server.mjs": "sha256:fc67ceced78239a1d159ff1af82b0061a407891523ad2a1a9139a3d4d158619c",
|
|
24
|
+
"src/producer-state.mjs": "sha256:61d6a914c268f94790c459562436bfe95efd07865ca1052b0b58760d9087d535",
|
|
25
25
|
"src/producer-support.mjs": "sha256:0a10f6fa50cd8e729ba432640d0845004488ead2bcc1c9383f02548037f83d66",
|
|
26
|
-
"src/producer.mjs": "sha256:
|
|
26
|
+
"src/producer.mjs": "sha256:50addd14e7ae0ae7fb41fb5a4c733f1876d0551befb6db909edeed6cb5f44c1a",
|
|
27
27
|
"src/protocol-internals.mjs": "sha256:d08a0c72079d091792c5327adafac5ffc38ea20138a6c44b88879e7157a890b3",
|
|
28
28
|
"src/protocol.mjs": "sha256:1fe135a2e60922b517886a392c2b0c2250b773abda8b1a1fcd9af86df2be6dae",
|
|
29
|
+
"src/publication-terminal-receipt.mjs": "sha256:b4ecc620f7e4325ec9d42652b8f1620f609fad65b1400cd2810a0a2f7e7b6794",
|
|
30
|
+
"src/publication-terminal.mjs": "sha256:643663218b31526a47c88f6ea2475428d94e2450b49225c253f39d54de30fed2",
|
|
29
31
|
"src/snapshot-validator.mjs": "sha256:1279aa19e0932776bc1998935f70ca6fae65391f96b60e2d65bcab3b2cb46036",
|
|
30
32
|
"src/source-admission.mjs": "sha256:25e0e3cc163895c3f6b5057162538c4c313bd37e67956c63b2624df52517b437",
|
|
31
33
|
"src/source-non-admission.mjs": "sha256:fd513148f9cd8beb1faddf9ad30189913d387a3cd6bc67f3308e7b0cbee46396",
|
|
@@ -34,7 +36,7 @@
|
|
|
34
36
|
"types/index.d.ts": "sha256:92ef7b5600f05d52dcb646fc3e4d2b4944ead52ff1c9ee23bb9f4659bebf05a6",
|
|
35
37
|
"types/journal.d.ts": "sha256:79beb61af50b00400588a87426a7f5b38bf38d4b8224284dc6e0631378aa244e",
|
|
36
38
|
"types/pipeline.d.ts": "sha256:c9135e25fb852377c32a16ce781405393c5857886e3f77754b715747202c70a9",
|
|
37
|
-
"types/producer.d.ts": "sha256:
|
|
39
|
+
"types/producer.d.ts": "sha256:16cc19bd45fbf0cec564c2d6ec5d5ad00f3f866c89eb8af94e6b5c33ed1be22f",
|
|
38
40
|
"types/protocol.d.ts": "sha256:02c06986c81bfc4214169f62da8e0566704b71a5add58434dfca92c673fad4ce",
|
|
39
41
|
"types/source-store.d.ts": "sha256:ce2086dd682e8f57bc5e1581b411ec9fe1cf1cfba8dccafe38c22d102c2b9904",
|
|
40
42
|
"types/types.d.ts": "sha256:cdc6664854bffc371a3da45f29bf1b31f14fd61b66e8f8bae8309dd836f6de6e"
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
3
|
+
import { completeIdentity, keys, timestamp, uuid } from './protocol-internals.mjs';
|
|
4
|
+
import { validateOriginalDelivery } from './producer-support.mjs';
|
|
5
|
+
|
|
6
|
+
const domain = 'casecore.release-agent/publication-terminal/v1\0';
|
|
7
|
+
const sorted = (value) => {
|
|
8
|
+
if (Array.isArray(value)) return value.map(sorted);
|
|
9
|
+
if (value && typeof value === 'object')
|
|
10
|
+
return Object.fromEntries(
|
|
11
|
+
Object.keys(value)
|
|
12
|
+
.sort()
|
|
13
|
+
.map((key) => [key, sorted(value[key])])
|
|
14
|
+
);
|
|
15
|
+
assert.ok(value === null || ['string', 'boolean', 'number'].includes(typeof value));
|
|
16
|
+
assert.ok(typeof value !== 'number' || Number.isFinite(value));
|
|
17
|
+
return value;
|
|
18
|
+
};
|
|
19
|
+
const body = (receipt) =>
|
|
20
|
+
JSON.stringify(
|
|
21
|
+
sorted({
|
|
22
|
+
schema: receipt.schema,
|
|
23
|
+
kind: receipt.kind,
|
|
24
|
+
issuedAt: receipt.issuedAt,
|
|
25
|
+
proof: receipt.proof,
|
|
26
|
+
})
|
|
27
|
+
);
|
|
28
|
+
function secret(token) {
|
|
29
|
+
assert.ok(
|
|
30
|
+
typeof token === 'string' && token.length >= 43 && token.length <= 4096 && !/\s/.test(token),
|
|
31
|
+
'PUBLICATION_TERMINAL_TOKEN_REQUIRED'
|
|
32
|
+
);
|
|
33
|
+
return token;
|
|
34
|
+
}
|
|
35
|
+
export function validatePublicationTerminalReceipt(receipt) {
|
|
36
|
+
keys(receipt, ['schema', 'kind', 'issuedAt', 'proof', 'mac']);
|
|
37
|
+
assert.equal(receipt.schema, 1);
|
|
38
|
+
assert.equal(receipt.kind, 'publication_terminal');
|
|
39
|
+
timestamp(receipt.issuedAt);
|
|
40
|
+
assert.match(receipt.mac, /^[a-f0-9]{64}$/);
|
|
41
|
+
const proof = receipt.proof;
|
|
42
|
+
keys(proof, [
|
|
43
|
+
'schema',
|
|
44
|
+
'bindings',
|
|
45
|
+
'status',
|
|
46
|
+
'completedAt',
|
|
47
|
+
'artifactIdentity',
|
|
48
|
+
'readback',
|
|
49
|
+
'failure',
|
|
50
|
+
]);
|
|
51
|
+
assert.equal(proof.schema, 1);
|
|
52
|
+
assert.ok(['deployed', 'failed'].includes(proof.status));
|
|
53
|
+
timestamp(proof.completedAt);
|
|
54
|
+
const bindings = proof.bindings;
|
|
55
|
+
keys(bindings, [
|
|
56
|
+
'action',
|
|
57
|
+
'commandId',
|
|
58
|
+
'runId',
|
|
59
|
+
'installationKey',
|
|
60
|
+
'repository',
|
|
61
|
+
'ref',
|
|
62
|
+
'provider',
|
|
63
|
+
'candidateId',
|
|
64
|
+
'delivery',
|
|
65
|
+
]);
|
|
66
|
+
assert.ok(['publish', 'deploy'].includes(bindings.action));
|
|
67
|
+
assert.match(bindings.commandId, uuid);
|
|
68
|
+
assert.match(bindings.runId, uuid);
|
|
69
|
+
assert.match(bindings.installationKey, /^[\w.-]{1,200}$/);
|
|
70
|
+
assert.match(bindings.repository, /^[\w.-]+\/[\w.-]+$/);
|
|
71
|
+
assert.ok(
|
|
72
|
+
typeof bindings.ref === 'string' && bindings.ref.length > 0 && bindings.ref.length <= 200
|
|
73
|
+
);
|
|
74
|
+
assert.ok(['github', 'local'].includes(bindings.provider));
|
|
75
|
+
assert.ok(
|
|
76
|
+
typeof bindings.candidateId === 'string' &&
|
|
77
|
+
bindings.candidateId.length > 0 &&
|
|
78
|
+
bindings.candidateId.length <= 200
|
|
79
|
+
);
|
|
80
|
+
validateOriginalDelivery(bindings.delivery);
|
|
81
|
+
assert.ok(Date.parse(proof.completedAt) >= Date.parse(bindings.delivery.requestedAt));
|
|
82
|
+
if (proof.status === 'deployed') {
|
|
83
|
+
assert.ok(Date.parse(proof.completedAt) <= Date.parse(bindings.delivery.deadlineAt));
|
|
84
|
+
assert.ok(completeIdentity(proof.artifactIdentity));
|
|
85
|
+
} else assert.ok(proof.artifactIdentity === null || completeIdentity(proof.artifactIdentity));
|
|
86
|
+
assert.ok(proof.readback && typeof proof.readback === 'object');
|
|
87
|
+
assert.ok(proof.failure === null || (proof.failure && typeof proof.failure === 'object'));
|
|
88
|
+
assert.ok(Buffer.byteLength(body(receipt)) <= 262144, 'PUBLICATION_TERMINAL_RECEIPT_TOO_LARGE');
|
|
89
|
+
return receipt;
|
|
90
|
+
}
|
|
91
|
+
// Internal data constructor. Public sealing additionally requires the genuine
|
|
92
|
+
// active engine capability and durably writes the existing command journal.
|
|
93
|
+
export function createPublicationTerminalReceipt(proof, token) {
|
|
94
|
+
const receipt = {
|
|
95
|
+
schema: 1,
|
|
96
|
+
kind: 'publication_terminal',
|
|
97
|
+
issuedAt: new Date().toISOString(),
|
|
98
|
+
proof: structuredClone(proof),
|
|
99
|
+
mac: '',
|
|
100
|
+
};
|
|
101
|
+
receipt.mac = createHmac('sha256', secret(token))
|
|
102
|
+
.update(domain)
|
|
103
|
+
.update(body(receipt))
|
|
104
|
+
.digest('hex');
|
|
105
|
+
return validatePublicationTerminalReceipt(receipt);
|
|
106
|
+
}
|
|
107
|
+
/** Authenticated data only: this never creates or revives a WeakMap capability. */
|
|
108
|
+
export function verifyPublicationTerminalReceipt(receipt, token) {
|
|
109
|
+
validatePublicationTerminalReceipt(receipt);
|
|
110
|
+
const expected = createHmac('sha256', secret(token))
|
|
111
|
+
.update(domain)
|
|
112
|
+
.update(body(receipt))
|
|
113
|
+
.digest();
|
|
114
|
+
assert.ok(
|
|
115
|
+
timingSafeEqual(expected, Buffer.from(receipt.mac, 'hex')),
|
|
116
|
+
'PUBLICATION_TERMINAL_RECEIPT_AUTHENTICATION_REQUIRED'
|
|
117
|
+
);
|
|
118
|
+
return structuredClone(receipt);
|
|
119
|
+
}
|