@casecore/release-agent 0.1.3 → 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 +49 -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 +16 -14
- package/dist/publication-terminal-receipt.mjs +119 -0
- package/dist/publication-terminal.mjs +251 -0
- package/dist/source-non-admission.mjs +4 -2
- 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
|
|
@@ -140,6 +140,12 @@ Alvori — `release-control-source.json`. Для старого формата A
|
|
|
140
140
|
в миллисекундах UTC и доверенный `acquireGuard(command, context)`.
|
|
141
141
|
Срок восстановления ограничен 30 секундами и не меняет исходные T0 и deadline.
|
|
142
142
|
|
|
143
|
+
Начиная с SDK 0.1.4, `tenantId`, `workspaceId` и `parentRunId` принимают
|
|
144
|
+
каноническую запись UUID из PostgreSQL без ограничения битов версии и варианта.
|
|
145
|
+
`operationGeneration` сохраняет строгую проверку сгенерированного UUID.
|
|
146
|
+
Значения области доступа не преобразуются: факт должен точно совпасть
|
|
147
|
+
с удержанным контекстом и каноническим владением CaseCore.
|
|
148
|
+
|
|
143
149
|
Адаптер удерживает общую блокировку приёма, worker, конфигурации и публикации;
|
|
144
150
|
подтверждает остановку всех исходных обработчиков и операций; исключает поздний
|
|
145
151
|
приём; проверяет отсутствие команды в том же защищённом журнале и получает
|
|
@@ -275,3 +281,45 @@ RELEASE_AGENT_TARBALL=/absolute/path/casecore-release-agent-0.1.0.tgz \
|
|
|
275
281
|
|
|
276
282
|
Запуск проверок и сборок должен учитывать действующее разрешение владельца задачи;
|
|
277
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
|
}
|