@casecore/release-agent 0.1.4 → 0.1.6
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 +43 -1
- package/dist/exclusive-operation.mjs +3 -0
- 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 +41 -0
- package/dist/producer-publication-failure.mjs +16 -2
- package/dist/producer-server.mjs +29 -51
- package/dist/producer-state.mjs +22 -35
- package/dist/producer.d.ts +72 -2
- package/dist/producer.mjs +44 -0
- package/dist/provenance.json +15 -13
- package/dist/publication-terminal-receipt.mjs +119 -0
- package/dist/publication-terminal.mjs +251 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
## Подключение
|
|
14
14
|
|
|
15
15
|
```sh
|
|
16
|
-
pnpm add --save-exact @casecore/release-agent@0.1.
|
|
16
|
+
pnpm add --save-exact @casecore/release-agent@0.1.6
|
|
17
17
|
```
|
|
18
18
|
|
|
19
19
|
В проекте фиксируются точная версия и `pnpm-lock.yaml`. Поддерживается Node.js
|
|
@@ -281,3 +281,45 @@ RELEASE_AGENT_TARBALL=/absolute/path/casecore-release-agent-0.1.0.tgz \
|
|
|
281
281
|
|
|
282
282
|
Запуск проверок и сборок должен учитывать действующее разрешение владельца задачи;
|
|
283
283
|
наличие команд и тестов не является подтверждением их успешного выполнения.
|
|
284
|
+
|
|
285
|
+
## Завершение исходной попытки публикации
|
|
286
|
+
|
|
287
|
+
Начиная с 0.1.6 адаптер может реализовать `publicationTerminal(command, context)`.
|
|
288
|
+
Движок вызывает его после сохранения подтверждённого результата `deployed` либо
|
|
289
|
+
штатного `failed` до установки. Адаптер закрывает свой исходный журнал под своей
|
|
290
|
+
блокировкой; общий движок и исходные часы выполнения остаются единственными.
|
|
291
|
+
|
|
292
|
+
`readPublicationTerminalAuthority(context.authority, context.bindings)` возвращает
|
|
293
|
+
подтверждённый результат с точной исходной командой, исполнителем, кандидатом и
|
|
294
|
+
исходными часами. Поле `artifactIdentity` отдельно хранит подписанную идентичность
|
|
295
|
+
артефакта: её SHA может отличаться от SHA исходных изменений. Authority действует
|
|
296
|
+
только внутри ограниченного 30 секундами callback; скопированный объект, snapshot
|
|
297
|
+
или digest полномочия не дают. Перед записью адаптер проверяет `context.signal`.
|
|
298
|
+
|
|
299
|
+
Ошибка callback сохраняет фактический результат установки и записывается в
|
|
300
|
+
диагностику; она не подтверждает закрытие исходного журнала. Повторный
|
|
301
|
+
`reconcile(commandId)` и восстановление после перезапуска получают новое
|
|
302
|
+
полномочие только после свежей независимой проверки production либо штатного
|
|
303
|
+
восстановления FAILED с native ACK. Запись адаптера должна быть идемпотентной для
|
|
304
|
+
той же исходной попытки. Публикация 0.1.6 и обновление установок проверяются отдельно
|
|
305
|
+
от локальной готовности этого исходного контракта.
|
|
306
|
+
|
|
307
|
+
Для первой установки уже собранного артефакта тот же callback вызывается с
|
|
308
|
+
`bindings.action: 'deploy'`. Его `repository` и `ref` берутся из сохранённого
|
|
309
|
+
артефактного кандидата; исходные шесть полей `delivery` остаются неизменными.
|
|
310
|
+
Адаптер обязан реализовать `verifyArtifactDelivery(command, identity, delivery,
|
|
311
|
+
source, context)` и подтвердить подпись, исходную операцию и происхождение
|
|
312
|
+
артефакта. `context.completedAt` — сохранённое время завершения установки.
|
|
313
|
+
Повторное подтверждение после истечения срока проверяет этот результат и свежий
|
|
314
|
+
production readback; новую установку или продление срока оно не разрешает.
|
|
315
|
+
|
|
316
|
+
`await sealPublicationTerminalReceipt(authority, bindings, managementToken)`
|
|
317
|
+
сохраняет заверенный результат в существующем журнале движка перед возвратом.
|
|
318
|
+
`verifyPublicationTerminalReceipt(receipt, managementToken)` проверяет HMAC и
|
|
319
|
+
возвращает данные, которые не являются authority. Токен управления уже должен
|
|
320
|
+
быть настроен у обоих участников; передавать его с результатом нельзя.
|
|
321
|
+
Авторизованный `GET /v1/release-control/publications/:commandId/terminal` читает
|
|
322
|
+
сохранённый результат без нового workflow claim или запуска. Для результата,
|
|
323
|
+
принятого через существующий report, адаптер может предоставить
|
|
324
|
+
`readTerminalReceipt(commandId)`. Неизвестный результат возвращает 404;
|
|
325
|
+
неподтверждённые ошибки установки не получают такой результат.
|
|
@@ -18,6 +18,9 @@ function metadata(producer) {
|
|
|
18
18
|
assert.ok(state, 'DELIVERY_OPERATION_BARRIER_REQUIRED');
|
|
19
19
|
return state;
|
|
20
20
|
}
|
|
21
|
+
export function assertProducerOperation(producer) {
|
|
22
|
+
assert.ok(metadata(producer).activity > 0, 'DELIVERY_OPERATION_AUTHORITY_REQUIRED');
|
|
23
|
+
}
|
|
21
24
|
function pendingFailures(producer, state) {
|
|
22
25
|
return [
|
|
23
26
|
...Object.values(producer.state.commands),
|
|
@@ -151,6 +151,9 @@ export class ProducerAdmission extends ProducerState {
|
|
|
151
151
|
command: structuredClone(input),
|
|
152
152
|
baseline: structuredClone(current.installed),
|
|
153
153
|
...(publication && candidate ? { sourceCandidate: structuredClone(candidate) } : {}),
|
|
154
|
+
...(!publication && input.action === 'deploy' && candidate?.deliveryExecution
|
|
155
|
+
? { artifactCandidate: structuredClone(candidate) }
|
|
156
|
+
: {}),
|
|
154
157
|
...(candidate?.deliveryExecution ? { deliveryExecution: candidate.deliveryExecution } : {}),
|
|
155
158
|
ack,
|
|
156
159
|
};
|
|
@@ -6,7 +6,7 @@ export async function verifyPublicationDelivery(
|
|
|
6
6
|
producer,
|
|
7
7
|
command,
|
|
8
8
|
delivery,
|
|
9
|
-
{ fresh = true } = {}
|
|
9
|
+
{ fresh = true, verification } = {}
|
|
10
10
|
) {
|
|
11
11
|
assert.equal(command.action, 'publish');
|
|
12
12
|
validateOriginalDelivery(delivery, command.expectedSource.sourceCommit);
|
|
@@ -19,7 +19,7 @@ export async function verifyPublicationDelivery(
|
|
|
19
19
|
assert.deepEqual(item.deliveryExecution, delivery, 'Original published delivery changed');
|
|
20
20
|
if (typeof producer.provider.verifyPublicationDelivery === 'function')
|
|
21
21
|
assert.equal(
|
|
22
|
-
await producer.provider.verifyPublicationDelivery(command, delivery),
|
|
22
|
+
await producer.provider.verifyPublicationDelivery(command, delivery, verification),
|
|
23
23
|
true,
|
|
24
24
|
'Original delivery correlation was not verified'
|
|
25
25
|
);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
|
+
import { notifyPublicationTerminal } from './publication-terminal.mjs';
|
|
2
3
|
import { canonical } from './protocol-internals.mjs';
|
|
3
4
|
import { sameIdentity, acceptedProduction } from './protocol.mjs';
|
|
4
5
|
import { blocker, failureMessage, failedProduction } from './producer-support.mjs';
|
|
@@ -6,10 +7,7 @@ import { ProducerPublication } from './producer-publication.mjs';
|
|
|
6
7
|
|
|
7
8
|
export class ProducerExecution extends ProducerPublication {
|
|
8
9
|
async qualifyForwardRecovery(run, command) {
|
|
9
|
-
if (
|
|
10
|
-
command.action === 'publish' &&
|
|
11
|
-
!this.state.commands[command.commandId]?.artifactIdentity
|
|
12
|
-
)
|
|
10
|
+
if (command.action === 'publish' && !this.state.commands[command.commandId]?.artifactIdentity)
|
|
13
11
|
return;
|
|
14
12
|
command = this.resolvedCommand(command);
|
|
15
13
|
if (
|
|
@@ -49,33 +47,27 @@ export class ProducerExecution extends ProducerPublication {
|
|
|
49
47
|
run.committedReadback = fact;
|
|
50
48
|
run.completedAt ??= this.now().toISOString();
|
|
51
49
|
for (const row of run.stages.filter((s) =>
|
|
52
|
-
['running', 'unverified'].includes(s.status)
|
|
50
|
+
['running', 'unverified'].includes(s.status)
|
|
53
51
|
)) {
|
|
54
52
|
row.status = 'failed';
|
|
55
53
|
row.completedAt ??= run.completedAt;
|
|
56
54
|
row.elapsedMs = row.startedAt
|
|
57
|
-
? Math.max(
|
|
58
|
-
0,
|
|
59
|
-
Date.parse(row.completedAt) - Date.parse(row.startedAt),
|
|
60
|
-
)
|
|
55
|
+
? Math.max(0, Date.parse(row.completedAt) - Date.parse(row.startedAt))
|
|
61
56
|
: null;
|
|
62
|
-
row.failure = blocker(
|
|
63
|
-
'INTERRUPTED_PROOF',
|
|
64
|
-
'Подтверждение результата было прервано',
|
|
65
|
-
);
|
|
57
|
+
row.failure = blocker('INTERRUPTED_PROOF', 'Подтверждение результата было прервано');
|
|
66
58
|
}
|
|
67
59
|
run.blockers.push(
|
|
68
60
|
blocker(
|
|
69
61
|
'FORWARD_CORRECTION_REQUIRED',
|
|
70
|
-
'Установка завершена, приёмка не пройдена; требуется исправляющий релиз'
|
|
71
|
-
)
|
|
62
|
+
'Установка завершена, приёмка не пройдена; требуется исправляющий релиз'
|
|
63
|
+
)
|
|
72
64
|
);
|
|
73
65
|
this.event(
|
|
74
66
|
run.id,
|
|
75
67
|
null,
|
|
76
68
|
'failure',
|
|
77
69
|
'Разрешён исправляющий релиз; исходная выкатка завершилась ошибкой',
|
|
78
|
-
'error'
|
|
70
|
+
'error'
|
|
79
71
|
);
|
|
80
72
|
}
|
|
81
73
|
} catch (qualificationError) {
|
|
@@ -87,13 +79,12 @@ export class ProducerExecution extends ProducerPublication {
|
|
|
87
79
|
}
|
|
88
80
|
async execute(command, { activated = false } = {}) {
|
|
89
81
|
const run = this.state.runs.find((r) => r.commandId === command.commandId);
|
|
90
|
-
const remaining = () =>
|
|
91
|
-
Date.parse(command.deadlineAt) - this.now().getTime();
|
|
82
|
+
const remaining = () => Date.parse(command.deadlineAt) - this.now().getTime();
|
|
92
83
|
let suspended = false;
|
|
93
84
|
const stage = async (
|
|
94
85
|
id,
|
|
95
86
|
operation,
|
|
96
|
-
{ inputsDigest = null, reusedFromRunId = null, queuedAt = null } = {}
|
|
87
|
+
{ inputsDigest = null, reusedFromRunId = null, queuedAt = null } = {}
|
|
97
88
|
) => {
|
|
98
89
|
assert.ok(remaining() > 0, 'Original deadline exceeded');
|
|
99
90
|
if (id === 'deploy') run.recoveryState = 'unverified';
|
|
@@ -103,8 +94,7 @@ export class ProducerExecution extends ProducerPublication {
|
|
|
103
94
|
command.action === 'publish' &&
|
|
104
95
|
this.provider.remotePublication === true &&
|
|
105
96
|
row.startedAt !== null;
|
|
106
|
-
if (!retained || !['passed', 'reused'].includes(row.status))
|
|
107
|
-
row.status = 'running';
|
|
97
|
+
if (!retained || !['passed', 'reused'].includes(row.status)) row.status = 'running';
|
|
108
98
|
if (!retained) row.startedAt = this.now().toISOString();
|
|
109
99
|
if (!retained) row.inputsDigest = inputsDigest;
|
|
110
100
|
if (!retained)
|
|
@@ -149,10 +139,7 @@ export class ProducerExecution extends ProducerPublication {
|
|
|
149
139
|
} finally {
|
|
150
140
|
if (!suspended) {
|
|
151
141
|
row.completedAt = this.now().toISOString();
|
|
152
|
-
row.elapsedMs = Math.max(
|
|
153
|
-
0,
|
|
154
|
-
Date.parse(row.completedAt) - Date.parse(row.startedAt),
|
|
155
|
-
);
|
|
142
|
+
row.elapsedMs = Math.max(0, Date.parse(row.completedAt) - Date.parse(row.startedAt));
|
|
156
143
|
this.event(run.id, id, 'stage', `${row.title}: ${row.status}`);
|
|
157
144
|
}
|
|
158
145
|
await this.save();
|
|
@@ -160,21 +147,15 @@ export class ProducerExecution extends ProducerPublication {
|
|
|
160
147
|
};
|
|
161
148
|
try {
|
|
162
149
|
if (command.lane === 'scheduled' && !activated) {
|
|
163
|
-
assert.ok(
|
|
164
|
-
this.provider.announce,
|
|
165
|
-
'Scheduled notice transport is not configured',
|
|
166
|
-
);
|
|
150
|
+
assert.ok(this.provider.announce, 'Scheduled notice transport is not configured');
|
|
167
151
|
await this.provider.announce(command);
|
|
168
152
|
run.noticeAt = this.now().toISOString();
|
|
169
|
-
if (
|
|
170
|
-
Date.parse(command.activationAt) <
|
|
171
|
-
Date.parse(run.noticeAt) + 86400000
|
|
172
|
-
) {
|
|
153
|
+
if (Date.parse(command.activationAt) < Date.parse(run.noticeAt) + 86400000) {
|
|
173
154
|
run.status = 'blocked';
|
|
174
155
|
run.blockers = [
|
|
175
156
|
blocker(
|
|
176
157
|
'NOTICE_WINDOW_TOO_SHORT',
|
|
177
|
-
'От фактического уведомления до установки должно пройти минимум 24 часа'
|
|
158
|
+
'От фактического уведомления до установки должно пройти минимум 24 часа'
|
|
178
159
|
),
|
|
179
160
|
];
|
|
180
161
|
await this.save();
|
|
@@ -184,11 +165,7 @@ export class ProducerExecution extends ProducerPublication {
|
|
|
184
165
|
// Scheduling is durable; reconciliation resumes after the activation time.
|
|
185
166
|
run.status = 'queued';
|
|
186
167
|
run.blockers = [
|
|
187
|
-
blocker(
|
|
188
|
-
'SCHEDULED_ACTIVATION',
|
|
189
|
-
'Ожидается согласованное окно установки',
|
|
190
|
-
true,
|
|
191
|
-
),
|
|
168
|
+
blocker('SCHEDULED_ACTIVATION', 'Ожидается согласованное окно установки', true),
|
|
192
169
|
];
|
|
193
170
|
await this.save();
|
|
194
171
|
return;
|
|
@@ -202,27 +179,23 @@ export class ProducerExecution extends ProducerPublication {
|
|
|
202
179
|
admitted?.ack.state === 'accepted' &&
|
|
203
180
|
canonical(admitted.command) === canonical(command) &&
|
|
204
181
|
Number.isFinite(Date.parse(run.noticeAt)) &&
|
|
205
|
-
Date.parse(command.activationAt) >=
|
|
206
|
-
Date.parse(run.noticeAt) + 86400000 &&
|
|
182
|
+
Date.parse(command.activationAt) >= Date.parse(run.noticeAt) + 86400000 &&
|
|
207
183
|
Date.parse(command.activationAt) <= this.now().getTime();
|
|
208
184
|
await this.provider.execute(command, {
|
|
209
185
|
stage,
|
|
210
186
|
remainingMs: remaining,
|
|
211
187
|
admittedScheduled,
|
|
212
|
-
bindDelivery: (delivery) =>
|
|
213
|
-
this.bindPublicationDelivery(command, delivery),
|
|
188
|
+
bindDelivery: (delivery) => this.bindPublicationDelivery(command, delivery),
|
|
214
189
|
bindArtifact: (identity, options) =>
|
|
215
190
|
this.bindPublicationArtifact(command, identity, options),
|
|
216
191
|
event: async (fact) => {
|
|
217
|
-
assert.ok(
|
|
218
|
-
['source_ready', 'quality', 'source_merge'].includes(fact.stage),
|
|
219
|
-
);
|
|
192
|
+
assert.ok(['source_ready', 'quality', 'source_merge'].includes(fact.stage));
|
|
220
193
|
assert.ok(['running', 'completed', 'failed'].includes(fact.status));
|
|
221
194
|
this.event(
|
|
222
195
|
run.id,
|
|
223
196
|
fact.stage === 'source_ready' ? 'source_ready' : 'ci',
|
|
224
197
|
'log',
|
|
225
|
-
`${fact.stage}: ${fact.status}
|
|
198
|
+
`${fact.stage}: ${fact.status}`
|
|
226
199
|
);
|
|
227
200
|
await this.save();
|
|
228
201
|
},
|
|
@@ -232,7 +205,7 @@ export class ProducerExecution extends ProducerPublication {
|
|
|
232
205
|
run.stages
|
|
233
206
|
.filter((s) => s.id !== 'production_acceptance')
|
|
234
207
|
.every((s) => ['passed', 'reused'].includes(s.status)),
|
|
235
|
-
'Every prerequisite stage needs actual proof'
|
|
208
|
+
'Every prerequisite stage needs actual proof'
|
|
236
209
|
);
|
|
237
210
|
const readback = await stage('production_acceptance', async (signal) => {
|
|
238
211
|
const fact = await this.provider.readProduction(resolved, { signal });
|
|
@@ -243,15 +216,13 @@ export class ProducerExecution extends ProducerPublication {
|
|
|
243
216
|
}
|
|
244
217
|
assert.ok(
|
|
245
218
|
acceptedProduction(fact, resolved, this.now()),
|
|
246
|
-
'Independent production identity and functional proof required'
|
|
219
|
+
'Independent production identity and functional proof required'
|
|
247
220
|
);
|
|
248
221
|
return fact;
|
|
249
222
|
});
|
|
250
223
|
run.recoveryState = 'verified';
|
|
251
224
|
if (command.action === 'rollback') {
|
|
252
|
-
const previous = this.state.runs.find(
|
|
253
|
-
(r) => r.id === command.previousRunId,
|
|
254
|
-
);
|
|
225
|
+
const previous = this.state.runs.find((r) => r.id === command.previousRunId);
|
|
255
226
|
if (previous) {
|
|
256
227
|
previous.status = 'failed';
|
|
257
228
|
previous.recoveryState = 'verified';
|
|
@@ -259,7 +230,7 @@ export class ProducerExecution extends ProducerPublication {
|
|
|
259
230
|
previous.blockers = [
|
|
260
231
|
blocker(
|
|
261
232
|
'RECOVERY_VERIFIED',
|
|
262
|
-
'Предыдущая установка восстановлена; исходная выкатка завершилась ошибкой'
|
|
233
|
+
'Предыдущая установка восстановлена; исходная выкатка завершилась ошибкой'
|
|
263
234
|
),
|
|
264
235
|
];
|
|
265
236
|
}
|
|
@@ -270,7 +241,7 @@ export class ProducerExecution extends ProducerPublication {
|
|
|
270
241
|
run.id,
|
|
271
242
|
null,
|
|
272
243
|
'readback',
|
|
273
|
-
'Производственная версия и функциональная проверка подтверждены'
|
|
244
|
+
'Производственная версия и функциональная проверка подтверждены'
|
|
274
245
|
);
|
|
275
246
|
} catch (error) {
|
|
276
247
|
if (
|
|
@@ -288,19 +259,17 @@ export class ProducerExecution extends ProducerPublication {
|
|
|
288
259
|
remaining() <= 0 ? 'DEADLINE_EXCEEDED' : 'RECONCILIATION_REQUIRED',
|
|
289
260
|
remaining() <= 0
|
|
290
261
|
? 'Исходный срок выполнения истёк'
|
|
291
|
-
: 'Выполнение требует проверки фактического результата'
|
|
262
|
+
: 'Выполнение требует проверки фактического результата'
|
|
292
263
|
),
|
|
293
264
|
];
|
|
294
265
|
this.event(run.id, null, 'failure', run.blockers[0].message, 'error');
|
|
295
266
|
await this.qualifyForwardRecovery(run, command);
|
|
296
267
|
} finally {
|
|
297
|
-
if (
|
|
298
|
-
!suspended &&
|
|
299
|
-
!run.blockers.some((b) => b.code === 'SCHEDULED_ACTIVATION')
|
|
300
|
-
)
|
|
268
|
+
if (!suspended && !run.blockers.some((b) => b.code === 'SCHEDULED_ACTIVATION'))
|
|
301
269
|
run.completedAt = this.now().toISOString();
|
|
302
270
|
await this.save();
|
|
303
271
|
}
|
|
272
|
+
await notifyPublicationTerminal(this, this.state.commands[command.commandId], run);
|
|
304
273
|
}
|
|
305
274
|
async resumeScheduled() {
|
|
306
275
|
if (this.scheduling) return;
|
|
@@ -310,11 +279,11 @@ export class ProducerExecution extends ProducerPublication {
|
|
|
310
279
|
(r) =>
|
|
311
280
|
r.status === 'queued' &&
|
|
312
281
|
r.lane === 'scheduled' &&
|
|
313
|
-
Date.parse(r.activationAt) <= this.now().getTime()
|
|
282
|
+
Date.parse(r.activationAt) <= this.now().getTime()
|
|
314
283
|
)) {
|
|
315
284
|
const command = this.state.commands[run.commandId].command;
|
|
316
285
|
const candidate = (await this.snapshot()).candidates.find(
|
|
317
|
-
(c) => c.id === command.candidateId
|
|
286
|
+
(c) => c.id === command.candidateId
|
|
318
287
|
);
|
|
319
288
|
if (
|
|
320
289
|
Date.parse(command.deadlineAt) <= this.now().getTime() ||
|
|
@@ -325,22 +294,19 @@ export class ProducerExecution extends ProducerPublication {
|
|
|
325
294
|
run.blockers = [
|
|
326
295
|
blocker(
|
|
327
296
|
'SCHEDULED_CANDIDATE_INVALID',
|
|
328
|
-
'Срок истёк или подготовленный кандидат изменился'
|
|
297
|
+
'Срок истёк или подготовленный кандидат изменился'
|
|
329
298
|
),
|
|
330
299
|
];
|
|
331
300
|
await this.save();
|
|
332
301
|
continue;
|
|
333
302
|
}
|
|
334
303
|
run.blockers = [];
|
|
335
|
-
if (
|
|
336
|
-
Date.parse(command.activationAt) <
|
|
337
|
-
Date.parse(run.noticeAt) + 86400000
|
|
338
|
-
) {
|
|
304
|
+
if (Date.parse(command.activationAt) < Date.parse(run.noticeAt) + 86400000) {
|
|
339
305
|
run.status = 'blocked';
|
|
340
306
|
run.blockers = [
|
|
341
307
|
blocker(
|
|
342
308
|
'NOTICE_WINDOW_TOO_SHORT',
|
|
343
|
-
'Недостаточно времени после фактического уведомления'
|
|
309
|
+
'Недостаточно времени после фактического уведомления'
|
|
344
310
|
),
|
|
345
311
|
];
|
|
346
312
|
await this.save();
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
2
|
import { hash, canonical, keys, uuid } from './protocol-internals.mjs';
|
|
3
3
|
import { validateRunnerCommand, validateProducerSnapshot } from './protocol.mjs';
|
|
4
|
+
import { validatePublicationTerminalReceipt } from './publication-terminal-receipt.mjs';
|
|
4
5
|
import {
|
|
5
6
|
validatePublicationFailureRecord,
|
|
6
7
|
validatePublicationFailureReadback,
|
|
@@ -29,6 +30,46 @@ export function validProducerState(state, installationKey, sourcePolicy) {
|
|
|
29
30
|
assert.equal(item.ack.protocolVersion, 1);
|
|
30
31
|
assert.ok(['accepted', 'rejected'].includes(item.ack.state));
|
|
31
32
|
assert.match(item.ack.runId, uuid);
|
|
33
|
+
if (item.artifactCandidate) {
|
|
34
|
+
assert.equal(item.command.action, 'deploy');
|
|
35
|
+
assert.equal(
|
|
36
|
+
canonical(item.artifactCandidate.identity),
|
|
37
|
+
canonical(item.command.expectedIdentity)
|
|
38
|
+
);
|
|
39
|
+
assert.equal(
|
|
40
|
+
canonical(item.artifactCandidate.deliveryExecution),
|
|
41
|
+
canonical(item.deliveryExecution)
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
if (item.deliveryTerminalReceipt) {
|
|
45
|
+
const proof = validatePublicationTerminalReceipt(item.deliveryTerminalReceipt).proof;
|
|
46
|
+
assert.equal(proof.bindings.commandId, id);
|
|
47
|
+
assert.equal(proof.bindings.runId, item.ack.runId);
|
|
48
|
+
assert.equal(proof.bindings.installationKey, installationKey);
|
|
49
|
+
assert.equal(proof.bindings.action, item.command.action);
|
|
50
|
+
assert.equal(proof.bindings.candidateId, item.command.candidateId);
|
|
51
|
+
assert.equal(proof.bindings.provider, item.command.executionProvider);
|
|
52
|
+
assert.equal(canonical(proof.bindings.delivery), canonical(item.deliveryExecution));
|
|
53
|
+
const source =
|
|
54
|
+
item.command.action === 'publish'
|
|
55
|
+
? item.command.expectedSource
|
|
56
|
+
: item.artifactCandidate?.source;
|
|
57
|
+
assert.equal(proof.bindings.repository, source?.repository);
|
|
58
|
+
assert.equal(proof.bindings.ref, source?.ref);
|
|
59
|
+
assert.equal(
|
|
60
|
+
canonical({ identity: proof.artifactIdentity }),
|
|
61
|
+
canonical({
|
|
62
|
+
identity:
|
|
63
|
+
item.command.action === 'publish'
|
|
64
|
+
? (item.artifactIdentity ?? null)
|
|
65
|
+
: item.command.expectedIdentity,
|
|
66
|
+
})
|
|
67
|
+
);
|
|
68
|
+
const run = state.runs.find((value) => value.id === item.ack.runId);
|
|
69
|
+
assert.equal(run?.status, proof.status);
|
|
70
|
+
assert.equal(run?.completedAt, proof.completedAt);
|
|
71
|
+
assert.equal(run?.recoveryState, 'verified');
|
|
72
|
+
}
|
|
32
73
|
if (item.publicationFailureReconciliation !== undefined)
|
|
33
74
|
validatePublicationFailureRecord(
|
|
34
75
|
item.publicationFailureReconciliation,
|
|
@@ -11,6 +11,7 @@ import { acceptedProduction, sameIdentity } from './protocol.mjs';
|
|
|
11
11
|
import { validateOriginalDelivery, blocker } from './producer-support.mjs';
|
|
12
12
|
import { verifyPublicationDelivery } from './producer-delivery.mjs';
|
|
13
13
|
import { reservePublicationFailureReconciliation } from './exclusive-operation.mjs';
|
|
14
|
+
import { notifyPublicationTerminal } from './publication-terminal.mjs';
|
|
14
15
|
|
|
15
16
|
const stages = ['source_ready', 'ci', 'build', 'verify', 'publish'];
|
|
16
17
|
export function publicationFailureProofDigest(fact, baselineReadback) {
|
|
@@ -97,10 +98,20 @@ export function validatePublicationFailureRecord(record, command, delivery, base
|
|
|
97
98
|
assert.ok(record.baselineReadback, 'Settled recovery requires its production baseline proof');
|
|
98
99
|
}
|
|
99
100
|
|
|
100
|
-
export async function reconcilePublicationFailure(
|
|
101
|
+
export async function reconcilePublicationFailure(
|
|
102
|
+
producer,
|
|
103
|
+
item,
|
|
104
|
+
run,
|
|
105
|
+
{ terminalRetry = false } = {}
|
|
106
|
+
) {
|
|
101
107
|
assert.equal(item.ack.state, 'accepted');
|
|
102
108
|
assert.equal(item.command.action, 'publish');
|
|
103
|
-
assert.
|
|
109
|
+
assert.ok(
|
|
110
|
+
run.status === 'blocked' ||
|
|
111
|
+
(terminalRetry &&
|
|
112
|
+
run.status === 'failed' &&
|
|
113
|
+
item.publicationFailureReconciliation?.state === 'settled')
|
|
114
|
+
);
|
|
104
115
|
assert.ok(!item.artifactIdentity);
|
|
105
116
|
for (const id of ['deploy', 'production_acceptance']) {
|
|
106
117
|
const row = run.stages.find((stage) => stage.id === id);
|
|
@@ -137,7 +148,9 @@ export async function reconcilePublicationFailure(producer, item, run) {
|
|
|
137
148
|
fact: null,
|
|
138
149
|
baselineReadback: null,
|
|
139
150
|
};
|
|
151
|
+
if (terminalRetry) item.publicationFailureReconciliation.state = 'pending';
|
|
140
152
|
assert.equal(item.publicationFailureReconciliation.state, 'pending');
|
|
153
|
+
run.status = 'blocked';
|
|
141
154
|
run.recoveryState = 'unverified';
|
|
142
155
|
run.completedAt = null;
|
|
143
156
|
await bounded(producer.save());
|
|
@@ -267,4 +280,5 @@ export async function reconcilePublicationFailure(producer, item, run) {
|
|
|
267
280
|
if (guard && !settled && !releasing && typeof guard.release === 'function') void cleanup(guard);
|
|
268
281
|
lease.release({ resolved: settled });
|
|
269
282
|
}
|
|
283
|
+
if (settled) await notifyPublicationTerminal(producer, item, run);
|
|
270
284
|
}
|
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,9 +113,21 @@ 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 }
|
|
@@ -152,6 +221,7 @@ export function producerServer(
|
|
|
152
221
|
workflowRunHeader?: string;
|
|
153
222
|
claimPublication?(commandId: string, workflowRunId: number): Promise<unknown>;
|
|
154
223
|
reportPublication?(commandId: string, report: unknown): Promise<unknown>;
|
|
224
|
+
readTerminalReceipt?(commandId: string): Promise<PublicationTerminalReceipt | null | undefined>;
|
|
155
225
|
configure?(request: ConfigurationRequest): Promise<ConfigurationAcknowledgement | undefined>;
|
|
156
226
|
reconcileConfiguration?(
|
|
157
227
|
configurationId: string
|
package/dist/producer.mjs
CHANGED
|
@@ -3,6 +3,12 @@ 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
|
+
import { notifyPublicationTerminal } from './publication-terminal.mjs';
|
|
7
|
+
export {
|
|
8
|
+
readPublicationTerminalAuthority,
|
|
9
|
+
sealPublicationTerminalReceipt,
|
|
10
|
+
verifyPublicationTerminalReceipt,
|
|
11
|
+
} from './publication-terminal.mjs';
|
|
6
12
|
export { publicationFailureProofDigest } from './producer-publication-failure.mjs';
|
|
7
13
|
export {
|
|
8
14
|
sameIdentity,
|
|
@@ -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.6",
|
|
5
|
+
"sourceCommit": "70442860e0f89db88c78142c6f6a7e8073414af5",
|
|
6
6
|
"sourceVerified": true,
|
|
7
7
|
"sourceDirty": false,
|
|
8
8
|
"buildKind": "release",
|
|
9
|
-
"contentDigest": "sha256:
|
|
9
|
+
"contentDigest": "sha256:a41148b8adf594a5da507b1f8495b5b8c63e32b514d815c7bbce2634b0e6fb89",
|
|
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
14
|
"src/index.mjs": "sha256:76bda5e1dc376bb7033d8fdbddb0afa85c7c68bbe028aea078ffceaaa35ec75e",
|
|
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:9ec7c5e4ae6add8c0b64644673c3243946175fb5cc2dafcd5f1fdee2c6485ffd",
|
|
21
|
+
"src/producer-publication-failure.mjs": "sha256:9db4effbeae8287070e3c27133abecf2bf435a8c6917c3162f0efd3189ea9445",
|
|
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:d5a9fca0bcb14f8c757d7219fcb8f2d39a2bf7af05c9cce3b8f654fa3060552c",
|
|
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:f64ae58d8e5984caea148a97e2cdf7d52a77bc472fb17ec75b5af1350f763fcc",
|
|
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
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { ProducerState } from './producer-state.mjs';
|
|
3
|
+
import { assertProducerOperation } from './exclusive-operation.mjs';
|
|
4
|
+
import { canonical, hash } from './protocol-internals.mjs';
|
|
5
|
+
import { acceptedProduction, sameIdentity, validateExpectedSource } from './protocol.mjs';
|
|
6
|
+
import { verifyPublicationDelivery } from './producer-delivery.mjs';
|
|
7
|
+
import { validatePublicationFailureReadback } from './producer-publication-failure.mjs';
|
|
8
|
+
import { validateOriginalDelivery } from './producer-support.mjs';
|
|
9
|
+
import {
|
|
10
|
+
createPublicationTerminalReceipt,
|
|
11
|
+
verifyPublicationTerminalReceipt,
|
|
12
|
+
} from './publication-terminal-receipt.mjs';
|
|
13
|
+
export { verifyPublicationTerminalReceipt } from './publication-terminal-receipt.mjs';
|
|
14
|
+
|
|
15
|
+
const authorities = new WeakMap();
|
|
16
|
+
|
|
17
|
+
/** The capability is valid only inside the engine's terminal callback. */
|
|
18
|
+
export function readPublicationTerminalAuthority(authority, bindings) {
|
|
19
|
+
const record = authorities.get(authority);
|
|
20
|
+
assert.ok(record && Date.now() <= record.expiresAt, 'PUBLICATION_TERMINAL_AUTHORITY_REQUIRED');
|
|
21
|
+
assert.equal(
|
|
22
|
+
canonical(bindings),
|
|
23
|
+
canonical(record.proof.bindings),
|
|
24
|
+
'PUBLICATION_TERMINAL_BINDINGS_CHANGED'
|
|
25
|
+
);
|
|
26
|
+
return structuredClone(record.proof);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** DEP-TERMINAL-V1: mint only inside the genuine current engine callback. */
|
|
30
|
+
export async function sealPublicationTerminalReceipt(authority, bindings, token) {
|
|
31
|
+
const proof = readPublicationTerminalAuthority(authority, bindings);
|
|
32
|
+
const record = authorities.get(authority);
|
|
33
|
+
assertProducerOperation(record.producer);
|
|
34
|
+
assert.equal(
|
|
35
|
+
canonical(record.producer.journal.read().commands[bindings.commandId]),
|
|
36
|
+
canonical(record.item),
|
|
37
|
+
'PUBLICATION_TERMINAL_NOT_DURABLE'
|
|
38
|
+
);
|
|
39
|
+
assert.equal(
|
|
40
|
+
canonical(record.producer.journal.read().runs.find((run) => run.id === bindings.runId)),
|
|
41
|
+
record.durableRun,
|
|
42
|
+
'PUBLICATION_TERMINAL_NOT_DURABLE'
|
|
43
|
+
);
|
|
44
|
+
const receipt = createPublicationTerminalReceipt(proof, token);
|
|
45
|
+
const prior = record.item.deliveryTerminalReceipt;
|
|
46
|
+
if (prior) {
|
|
47
|
+
const existing = verifyPublicationTerminalReceipt(prior, token).proof;
|
|
48
|
+
for (const key of ['bindings', 'status', 'completedAt', 'artifactIdentity', 'failure'])
|
|
49
|
+
assert.equal(
|
|
50
|
+
canonical({ value: existing[key] }),
|
|
51
|
+
canonical({ value: proof[key] }),
|
|
52
|
+
'PUBLICATION_TERMINAL_RESULT_CHANGED'
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
record.item.deliveryTerminalReceipt = structuredClone(receipt);
|
|
56
|
+
await record.producer.save();
|
|
57
|
+
readPublicationTerminalAuthority(authority, bindings);
|
|
58
|
+
return structuredClone(receipt);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const fresh = (readback, now) =>
|
|
62
|
+
[readback.observedAt, readback.functionalSmoke.checkedAt].every((value) => {
|
|
63
|
+
const age = now.getTime() - Date.parse(value);
|
|
64
|
+
return age >= 0 && age <= 300000;
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// Internal issuance is deliberately absent from the package public exports.
|
|
68
|
+
export async function notifyPublicationTerminal(producer, item, run) {
|
|
69
|
+
if (
|
|
70
|
+
!['publish', 'deploy'].includes(item.command.action) ||
|
|
71
|
+
(item.command.action === 'deploy' && !item.deliveryExecution) ||
|
|
72
|
+
typeof producer.provider.publicationTerminal !== 'function'
|
|
73
|
+
)
|
|
74
|
+
return;
|
|
75
|
+
if (!['deployed', 'failed'].includes(run.status) || run.recoveryState !== 'verified') return;
|
|
76
|
+
assert.ok(ProducerState.isEngineInstance(producer), 'PUBLICATION_ENGINE_INSTANCE_REQUIRED');
|
|
77
|
+
assertProducerOperation(producer);
|
|
78
|
+
let authority;
|
|
79
|
+
let controller;
|
|
80
|
+
let timer;
|
|
81
|
+
try {
|
|
82
|
+
controller = new AbortController();
|
|
83
|
+
const expiresAt = Date.now() + 30000;
|
|
84
|
+
const timeout = new Promise((_, reject) => {
|
|
85
|
+
timer = setTimeout(() => {
|
|
86
|
+
controller.abort();
|
|
87
|
+
reject(Error('PUBLICATION_TERMINAL_CALLBACK_TIMEOUT'));
|
|
88
|
+
}, 30000);
|
|
89
|
+
});
|
|
90
|
+
const bounded = (operation) => Promise.race([operation, timeout]);
|
|
91
|
+
assert.ok(
|
|
92
|
+
Date.parse(run.completedAt) >= Date.parse(item.command.requestedAt) &&
|
|
93
|
+
Date.parse(run.completedAt) <= producer.now().getTime(),
|
|
94
|
+
'PUBLICATION_TERMINAL_COMPLETION_REQUIRED'
|
|
95
|
+
);
|
|
96
|
+
if (run.status === 'deployed')
|
|
97
|
+
assert.ok(
|
|
98
|
+
Date.parse(run.completedAt) <= Date.parse(item.deliveryExecution.deadlineAt),
|
|
99
|
+
'PUBLICATION_TERMINAL_COMPLETION_LATE'
|
|
100
|
+
);
|
|
101
|
+
const verification = {
|
|
102
|
+
signal: controller.signal,
|
|
103
|
+
completedAt: run.completedAt,
|
|
104
|
+
status: run.status,
|
|
105
|
+
artifactIdentity: item.artifactIdentity ?? item.command.expectedIdentity,
|
|
106
|
+
};
|
|
107
|
+
let source;
|
|
108
|
+
const artifactIdentity =
|
|
109
|
+
item.command.action === 'publish' ? item.artifactIdentity : item.command.expectedIdentity;
|
|
110
|
+
if (item.command.action === 'publish') {
|
|
111
|
+
source = item.command.expectedSource;
|
|
112
|
+
await bounded(
|
|
113
|
+
verifyPublicationDelivery(producer, item.command, item.deliveryExecution, {
|
|
114
|
+
fresh: false,
|
|
115
|
+
verification,
|
|
116
|
+
})
|
|
117
|
+
);
|
|
118
|
+
} else {
|
|
119
|
+
validateOriginalDelivery(item.deliveryExecution);
|
|
120
|
+
assert.equal(
|
|
121
|
+
Date.parse(item.command.requestedAt),
|
|
122
|
+
Date.parse(item.deliveryExecution.requestedAt)
|
|
123
|
+
);
|
|
124
|
+
assert.equal(
|
|
125
|
+
Date.parse(item.command.deadlineAt),
|
|
126
|
+
Date.parse(item.deliveryExecution.deadlineAt)
|
|
127
|
+
);
|
|
128
|
+
assert.equal(item.ack.state, 'accepted');
|
|
129
|
+
assert.equal(item.fingerprint, hash(canonical(item.command)));
|
|
130
|
+
assert.ok(
|
|
131
|
+
item.artifactCandidate && sameIdentity(item.artifactCandidate.identity, artifactIdentity),
|
|
132
|
+
'PUBLICATION_ARTIFACT_CANDIDATE_REQUIRED'
|
|
133
|
+
);
|
|
134
|
+
source = {
|
|
135
|
+
repository: item.artifactCandidate.source.repository,
|
|
136
|
+
ref: item.artifactCandidate.source.ref,
|
|
137
|
+
sourceCommit: artifactIdentity.sourceCommit,
|
|
138
|
+
};
|
|
139
|
+
assert.ok(producer.sourcePolicy?.repository, 'PUBLICATION_ARTIFACT_REPOSITORY_REQUIRED');
|
|
140
|
+
assert.match(source.ref, /^[\w./-]{1,200}$/);
|
|
141
|
+
// A feature branch admission policy does not describe the signed artifact's
|
|
142
|
+
// real main/tag source. The mandatory product verifier owns that lineage.
|
|
143
|
+
validateExpectedSource(source, {
|
|
144
|
+
repository: producer.sourcePolicy.repository,
|
|
145
|
+
ref: source.ref,
|
|
146
|
+
});
|
|
147
|
+
assert.equal(
|
|
148
|
+
typeof producer.provider.verifyArtifactDelivery,
|
|
149
|
+
'function',
|
|
150
|
+
'PUBLICATION_ARTIFACT_DELIVERY_VERIFIER_REQUIRED'
|
|
151
|
+
);
|
|
152
|
+
assert.equal(
|
|
153
|
+
await bounded(
|
|
154
|
+
producer.provider.verifyArtifactDelivery(
|
|
155
|
+
item.command,
|
|
156
|
+
artifactIdentity,
|
|
157
|
+
item.deliveryExecution,
|
|
158
|
+
structuredClone(source),
|
|
159
|
+
verification
|
|
160
|
+
)
|
|
161
|
+
),
|
|
162
|
+
true,
|
|
163
|
+
'PUBLICATION_LINEAGE_REQUIRED'
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
const saved = producer.journal.read();
|
|
167
|
+
assert.equal(
|
|
168
|
+
canonical(saved.commands[item.command.commandId]),
|
|
169
|
+
canonical(item),
|
|
170
|
+
'PUBLICATION_TERMINAL_NOT_DURABLE'
|
|
171
|
+
);
|
|
172
|
+
assert.equal(
|
|
173
|
+
canonical(saved.runs.find((value) => value.id === run.id)),
|
|
174
|
+
canonical(run),
|
|
175
|
+
'PUBLICATION_TERMINAL_NOT_DURABLE'
|
|
176
|
+
);
|
|
177
|
+
if (run.status === 'deployed') {
|
|
178
|
+
assert.ok(artifactIdentity, 'PUBLICATION_ARTIFACT_REQUIRED');
|
|
179
|
+
assert.ok(
|
|
180
|
+
acceptedProduction(run.readback, producer.resolvedCommand(item.command), producer.now()),
|
|
181
|
+
'PUBLICATION_TERMINAL_READBACK_REQUIRED'
|
|
182
|
+
);
|
|
183
|
+
assert.ok(fresh(run.readback, producer.now()), 'PUBLICATION_TERMINAL_READBACK_STALE');
|
|
184
|
+
if (item.command.action === 'publish')
|
|
185
|
+
assert.equal(
|
|
186
|
+
await bounded(
|
|
187
|
+
producer.provider.verifyPublicationArtifact(
|
|
188
|
+
item.command,
|
|
189
|
+
artifactIdentity,
|
|
190
|
+
item.deliveryExecution,
|
|
191
|
+
verification
|
|
192
|
+
)
|
|
193
|
+
),
|
|
194
|
+
true,
|
|
195
|
+
'PUBLICATION_LINEAGE_REQUIRED'
|
|
196
|
+
);
|
|
197
|
+
} else {
|
|
198
|
+
// The existing independently owned pre-install failure guard remains the
|
|
199
|
+
// sole failure authority until artifact recovery has an equivalent guard.
|
|
200
|
+
assert.equal(
|
|
201
|
+
item.publicationFailureReconciliation?.state,
|
|
202
|
+
'settled',
|
|
203
|
+
'PUBLICATION_FAILURE_NOT_SETTLED'
|
|
204
|
+
);
|
|
205
|
+
validatePublicationFailureReadback(run.readback, item.command, item.baseline, producer.now());
|
|
206
|
+
}
|
|
207
|
+
const bindings = {
|
|
208
|
+
action: item.command.action,
|
|
209
|
+
commandId: item.command.commandId,
|
|
210
|
+
runId: run.id,
|
|
211
|
+
installationKey: item.command.installationKey,
|
|
212
|
+
repository: source.repository,
|
|
213
|
+
ref: source.ref,
|
|
214
|
+
provider: item.command.executionProvider,
|
|
215
|
+
candidateId: item.command.candidateId,
|
|
216
|
+
delivery: structuredClone(item.deliveryExecution),
|
|
217
|
+
};
|
|
218
|
+
const proof = {
|
|
219
|
+
schema: 1,
|
|
220
|
+
bindings,
|
|
221
|
+
status: run.status,
|
|
222
|
+
completedAt: run.completedAt,
|
|
223
|
+
artifactIdentity: structuredClone(artifactIdentity ?? null),
|
|
224
|
+
readback: structuredClone(run.readback),
|
|
225
|
+
failure: structuredClone(item.publicationFailureReconciliation?.fact ?? null),
|
|
226
|
+
};
|
|
227
|
+
authority = Object.freeze(Object.create(null));
|
|
228
|
+
controller.signal.throwIfAborted();
|
|
229
|
+
authorities.set(authority, { proof, expiresAt, producer, item, durableRun: canonical(run) });
|
|
230
|
+
await bounded(
|
|
231
|
+
Promise.resolve().then(() =>
|
|
232
|
+
producer.provider.publicationTerminal(structuredClone(item.command), {
|
|
233
|
+
authority,
|
|
234
|
+
bindings: structuredClone(bindings),
|
|
235
|
+
signal: controller.signal,
|
|
236
|
+
})
|
|
237
|
+
)
|
|
238
|
+
);
|
|
239
|
+
} catch (error) {
|
|
240
|
+
// The deployment result remains truthful. Product origin closure is unproven
|
|
241
|
+
// and must retry through fresh official reconciliation, never a raw snapshot.
|
|
242
|
+
producer.diagnostic(run.id, null, error);
|
|
243
|
+
await producer.save();
|
|
244
|
+
return false;
|
|
245
|
+
} finally {
|
|
246
|
+
if (authority) authorities.delete(authority);
|
|
247
|
+
controller?.abort();
|
|
248
|
+
clearTimeout(timer);
|
|
249
|
+
}
|
|
250
|
+
return true;
|
|
251
|
+
}
|