diff --git a/CHANGELOG.md b/CHANGELOG.md index db246985..f337f27c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ ### Added +- The publish recovery routes are in the contract (#994, API contract 1.81.0, additive): `GET` and `DELETE /builds/{run_id}/publish/receipt`, `POST /builds/{run_id}/publish/reconcile` and `GET /builds/{run_id}/publish/audit` have answered since contract 1.19.0 and 1.20.0 but were described only in the contract's prose, so a client had no schema for them and the route tests did not know them. They are now declared with `PublishReceipt`, `PublishReconcileRequest`, `PublishReconcileResponse`, `PublishReceiptReset` and `PublishAuditLog`, and their responses are checked against those schemas. Nothing on the wire changed. - Four failures a client had to tell apart by their sentence have a stable `code` (#1000, API contract 1.79.0, additive). The full async build queue answers 429 `build_queue_full` — it shared its status with `auth_throttled` and had no code. An authentication failure answers `unauthorized`, or `token_expired` when the bearer token's `exp` has passed, or 503 `auth_unavailable` when the JWKS could not be fetched. The overload 503 written to the socket before a request is read answers `server_overloaded`. Every body keeps its `error` text. A run a restart interrupted got its code, `credentials_required`, with #996. - `card.json` is declared in the contract as `DatasetCard` and states the licence mismatch and "no processing" as fields (#955, API contract 1.75.0, additive). The card sections every Gold output has carried since 1.71.0 (#694) had no schema, and two facts lived only in sentences a client had to match: `…; the provider declares: …` and `No transformation declared: values are as the source gave them.` Each `provenance` entry now also has `license_declared` (the BuildSpec's licence as written, with its link; null when none), `license_provider` (the kpubdata catalog's terms; null when none) and `license_mismatch` (both known and the provider's terms are not in the declared licence — the condition the sentence already used), and the card has `processing_declared` (false when only the no-transformation sentence is listed; a composed output's join counts as processing). The sentences are unchanged for the README. `GET /artifacts/{run_id}/{file_path}` says `gold//card.json` is a `DatasetCard` and gains an `application/json` example of one, so `contract/fixtures/responses.json` carries card fixtures. Tests validate real `card_sections` output, from the functions and from single and composed builds, against the schema, and fail when the writer sends a field the schema does not name. Cards written before 1.75.0 lack the four fields. - `GET /admin/runs` gives `total`, how many runs there are before `limit` (#948, API contract 1.73.0, additive). `count` was always the runs in the response — `len(runs)` after the `limit` cut — and is now documented as that, not a total; `total` is the build index's runs plus the queued and running jobs it does not hold yet, each counted once, with one `COUNT(*)` and a key lookup rather than reading every row (`BuildIndex.count_builds`, SQLite and CUBRID). `count < total` means older runs were left out, so an admin screen can say "N of M". A client talking to an older Builder finds `total` absent. diff --git a/contract/builder-api.yaml b/contract/builder-api.yaml index 62757f7c..e909c9de 100644 --- a/contract/builder-api.yaml +++ b/contract/builder-api.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: KPubData Builder Service API - version: "1.80.0" + version: "1.81.0" description: >- KPubData Builder(패키지 `kpubdata-builder`) 서비스 계약. BuildSpec 검증, preview, build 실행, artifact 조회, 계약 버전 조회를 제공한다. CLI와 HTTP service mode(#36)가 동일한 도메인 계약을 @@ -20,6 +20,7 @@ info: 추가한다(#504, additive). v1.8.0은 stable principal별 encrypted Provider credential CRUD와 Provider status/test API를 추가한다(#492, additive). v1.9.0은 `/query` 응답에 child startup과 Polars engine 실행 시간을 추가한다(#523, additive). + v1.81.0 declares the publish recovery routes the service has answered since v1.19.0 and v1.20.0 (#994, additive): `GET` and `DELETE /builds/{run_id}/publish/receipt`, `POST /builds/{run_id}/publish/reconcile` and `GET /builds/{run_id}/publish/audit` were described only in this text, so a client had no schema to check them against. Nothing on the wire changed. v1.80.0 declares what the service already sends (#994, description only — the wire is unchanged): the `X-Provider-Key` request header as a parameter of the five operations that call a provider (`previewBuild`, `createBuild`, `submitBuild`, `getProviderStatus`, `testProviderConnection`), the 429 `auth_throttled` and the overload 503 as shared responses any operation can answer (`components.responses.AuthThrottled`, `ServerOverloaded`), and the `X-Request-ID` response header (`components.headers.RequestId`). v1.79.0 gives four failures a stable `code` (#1000, additive): the full async build queue's 429 is `build_queue_full` (it was told from 429 `auth_throttled` only by its sentence), an authentication failure is `unauthorized`, `token_expired` (the bearer token's `exp` has passed — get a new token) or, with 503, `auth_unavailable` (the JWKS could not be fetched), and the overload 503 written before a request is read is `server_overloaded`. Each body keeps its `error` text. v1.78.0 lets the owner read a run a restart interrupted (#996, additive): `GET /builds/{run_id}` answers such a run as `failed` with the new optional `BuildJob.code` `credentials_required` and the reason in `error`, and `GET /builds/{run_id}/events` shows its `run_failed` event, where both answered 404 before; another user still gets what a missing run gets. The submitter of an async run is now recorded with its events, so this holds for runs submitted from this version on. @@ -1973,6 +1974,280 @@ paths: schema: $ref: "#/components/schemas/Error" + /builds/{run_id}/publish/receipt: + get: + operationId: getPublishReceipt + summary: >- + The caller's publish receipt for one target and destination (#551). A publish that + answered 409 `publish_state_unknown` left a receipt in state `unknown`; this is + how its state is read before choosing between reconcile and reset. + parameters: + - $ref: "#/components/parameters/RunId" + - $ref: "#/components/parameters/PublishCredential" + - name: target + in: query + required: true + description: The publish target the receipt was written for. + schema: + $ref: "#/components/schemas/PublishTarget" + - name: destination + in: query + required: true + description: The destination the receipt was written for, as sent to `POST .../publish`. + schema: + type: string + responses: + "200": + description: The receipt + content: + application/json: + schema: + $ref: "#/components/schemas/PublishReceipt" + examples: + Unknown: + summary: The publish outcome was not learnt, so a retry is blocked + value: + run_id: air-quality-20260815 + target: huggingface + destination: kpubdata/air-quality + state: unknown + fingerprint: 9f2c1d7a4b6e8035 + options: {} + reconcilable: true + "400": + description: An unsafe run_id, or `target` or `destination` missing + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + description: 해당 run의 소유자가 아님 + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "404": + description: >- + The run is not found, or the caller has no receipt for this target and + destination (`receipt_not_found`). Another owner's receipt answers the same. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + examples: + ReceiptNotFound: + value: + error: publish receipt not found + code: receipt_not_found + delete: + operationId: resetPublishReceipt + summary: >- + Delete the caller's receipt, in any state, so the same publish can be claimed again + (#551). Nothing is undone remotely — what was already published stays published. + The reset is recorded in the audit history. + parameters: + - $ref: "#/components/parameters/RunId" + - $ref: "#/components/parameters/PublishCredential" + - name: target + in: query + required: true + description: The publish target the receipt was written for. + schema: + $ref: "#/components/schemas/PublishTarget" + - name: destination + in: query + required: true + description: The destination the receipt was written for, as sent to `POST .../publish`. + schema: + type: string + responses: + "200": + description: The receipt was deleted + content: + application/json: + schema: + $ref: "#/components/schemas/PublishReceiptReset" + examples: + Reset: + value: + run_id: air-quality-20260815 + state: reset + retry_allowed: true + fingerprint: 9f2c1d7a4b6e8035 + "400": + description: An unsafe run_id, or `target` or `destination` missing + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + description: 해당 run의 소유자가 아님 + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "404": + description: >- + The run is not found, or the caller has no receipt for this target and + destination (`receipt_not_found`). Another owner's receipt answers the same. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + examples: + ReceiptNotFound: + value: + error: publish receipt not found + code: receipt_not_found + "503": + description: >- + `reconcile_unavailable`: the reset could not be persisted. Nothing was changed; the same request may be + sent again. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + examples: + ReconcileUnavailable: + value: + error: receipt reset could not be persisted; nothing was changed + code: reconcile_unavailable + + /builds/{run_id}/publish/reconcile: + post: + operationId: reconcilePublish + summary: >- + Settle a receipt by looking at the remote (#551). When the destination exists + there, the receipt becomes `succeeded`; when it does not, the receipt is deleted + so the publish can be sent again (`state: reset`). A receipt already `succeeded` + is returned as it is, with `reconciled: false` and no remote call. When the + remote cannot be read, nothing changes and the answer is 503. + parameters: + - $ref: "#/components/parameters/RunId" + - $ref: "#/components/parameters/PublishCredential" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/PublishReconcileRequest" + responses: + "200": + description: The receipt's state after the check + content: + application/json: + schema: + $ref: "#/components/schemas/PublishReconcileResponse" + examples: + Confirmed: + summary: The destination exists remotely + value: + run_id: air-quality-20260815 + state: succeeded + reconciled: true + fingerprint: 9f2c1d7a4b6e8035 + AbsentReset: + summary: Nothing was found remotely, so the publish may be sent again + value: + run_id: air-quality-20260815 + state: reset + reconciled: true + retry_allowed: true + fingerprint: 9f2c1d7a4b6e8035 + "400": + description: >- + An unsafe run_id, a body that is not an object, a field other than `target` and + `destination`, an unsupported target (`unsupported_target`) or a destination + the target does not accept + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + description: 해당 run의 소유자가 아님 + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "404": + description: >- + The run is not found, or the caller has no receipt for this target and + destination (`receipt_not_found`). Another owner's receipt answers the same. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + examples: + ReceiptNotFound: + value: + error: publish receipt not found + code: receipt_not_found + "503": + description: >- + `reconcile_unavailable`: the remote state could not be read, or the outcome could not be persisted. Nothing was changed; the same request may be + sent again. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + examples: + ReconcileUnavailable: + value: + error: remote state could not be determined; nothing was changed + code: reconcile_unavailable + + /builds/{run_id}/publish/audit: + get: + operationId: getPublishAudit + summary: >- + The caller's reconcile and reset history for a run (#563), oldest first. It + outlives the receipts: a reset deletes the receipt and leaves its entry here. + An entry holds no credential and no path. + parameters: + - $ref: "#/components/parameters/RunId" + responses: + "200": + description: The history; `entries` is empty when nothing was reconciled or reset + content: + application/json: + schema: + $ref: "#/components/schemas/PublishAuditLog" + examples: + OneReset: + value: + run_id: air-quality-20260815 + entries: + - fingerprint: 9f2c1d7a4b6e8035 + action: manual_reset + actor: operator + recorded_at: "2026-08-15T03:12:45+00:00" + "400": + description: An unsafe run_id + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + description: 해당 run의 소유자가 아님 + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "404": + description: 해당 run을 찾을 수 없음 + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + /builds/{run_id}/publish: post: operationId: publishBuild @@ -9075,3 +9350,104 @@ components: properties: export_id: {type: string} deleted: {type: boolean, const: true} + PublishReceipt: + type: object + description: >- + What the service remembers of one publish, per owner, run, target and destination + (#551). `fingerprint` identifies the claim; it is not a secret and not a path. + additionalProperties: false + required: [run_id, target, destination, state, fingerprint, options, reconcilable] + properties: + run_id: {type: string} + target: + $ref: "#/components/schemas/PublishTarget" + destination: {type: string} + state: + type: string + enum: [pending, succeeded, unknown] + description: >- + `pending` — a publish holds the claim and has not finished. `succeeded` — it + finished, and `result` is its response. `unknown` — it ended without the + outcome being learnt; a retry is blocked until the receipt is reconciled or + reset. + fingerprint: {type: string} + options: + type: object + description: The `options` the publish was sent with. + additionalProperties: true + reconcilable: + type: boolean + description: True when `state` is `unknown`. + result: + type: object + description: The publish response recorded for a `succeeded` receipt. + additionalProperties: true + PublishReconcileRequest: + type: object + additionalProperties: false + required: [target, destination] + properties: + target: + $ref: "#/components/schemas/PublishTarget" + destination: + type: string + description: The destination the receipt was written for. + PublishReconcileResponse: + type: object + additionalProperties: false + required: [run_id, state, reconciled, fingerprint] + properties: + run_id: {type: string} + state: + type: string + enum: [succeeded, reset] + description: >- + `succeeded` — the destination exists remotely, or the receipt already said so. + `reset` — nothing was found remotely and the receipt was deleted. + reconciled: + type: boolean + description: False only when the receipt was already `succeeded` and no check ran. + fingerprint: {type: string} + retry_allowed: + type: boolean + description: Sent, as true, when `state` is `reset`. + result: + type: object + description: The recorded publish response, when the receipt was already `succeeded`. + additionalProperties: true + PublishReceiptReset: + type: object + additionalProperties: false + required: [run_id, state, retry_allowed, fingerprint] + properties: + run_id: {type: string} + state: {type: string, const: reset} + retry_allowed: {type: boolean, const: true} + fingerprint: {type: string} + PublishAuditLog: + type: object + additionalProperties: false + required: [run_id, entries] + properties: + run_id: {type: string} + entries: + type: array + items: + $ref: "#/components/schemas/PublishAuditEntry" + PublishAuditEntry: + type: object + additionalProperties: false + required: [fingerprint, action, actor, recorded_at] + properties: + fingerprint: {type: string} + action: + type: string + description: >- + What was done to the receipt: `manual_reset` (`DELETE .../publish/receipt`), + `reconcile_succeeded` (a reconcile that found the destination remotely) or + `reconcile_absent_reset` (one that found nothing). Read it as text: later + versions may record more actions. + actor: {type: string} + recorded_at: + type: string + description: When it was recorded, ISO 8601. diff --git a/contract/fixtures/responses.json b/contract/fixtures/responses.json index adc490c1..aa02e4a4 100644 --- a/contract/fixtures/responses.json +++ b/contract/fixtures/responses.json @@ -1,6 +1,6 @@ { "fixture_format": 1, - "contract_version": "1.80.0", + "contract_version": "1.81.0", "probe_field": "future_optional_field", "rules": "A response may gain optional fields in any minor version; a client ignores fields it does not know. A required field keeps its name and type until the next major version; a client rejects a body whose required field is missing or mistyped.", "fixtures": [ @@ -2317,6 +2317,185 @@ }, "broken_path": "$.run_id" }, + { + "operation_id": "getPublishReceipt", + "method": "GET", + "path": "/builds/{run_id}/publish/receipt", + "status": 200, + "example": "Unknown", + "current": { + "run_id": "air-quality-20260815", + "target": "huggingface", + "destination": "kpubdata/air-quality", + "state": "unknown", + "fingerprint": "9f2c1d7a4b6e8035", + "options": {}, + "reconcilable": true + }, + "with_additive_fields": { + "run_id": "air-quality-20260815", + "target": "huggingface", + "destination": "kpubdata/air-quality", + "state": "unknown", + "fingerprint": "9f2c1d7a4b6e8035", + "options": {}, + "reconcilable": true, + "future_optional_field": "added by a later minor contract version" + }, + "additive_paths": [ + "$" + ], + "required_type_broken": { + "run_id": 12345, + "target": "huggingface", + "destination": "kpubdata/air-quality", + "state": "unknown", + "fingerprint": "9f2c1d7a4b6e8035", + "options": {}, + "reconcilable": true + }, + "broken_path": "$.run_id" + }, + { + "operation_id": "resetPublishReceipt", + "method": "DELETE", + "path": "/builds/{run_id}/publish/receipt", + "status": 200, + "example": "Reset", + "current": { + "run_id": "air-quality-20260815", + "state": "reset", + "retry_allowed": true, + "fingerprint": "9f2c1d7a4b6e8035" + }, + "with_additive_fields": { + "run_id": "air-quality-20260815", + "state": "reset", + "retry_allowed": true, + "fingerprint": "9f2c1d7a4b6e8035", + "future_optional_field": "added by a later minor contract version" + }, + "additive_paths": [ + "$" + ], + "required_type_broken": { + "run_id": 12345, + "state": "reset", + "retry_allowed": true, + "fingerprint": "9f2c1d7a4b6e8035" + }, + "broken_path": "$.run_id" + }, + { + "operation_id": "reconcilePublish", + "method": "POST", + "path": "/builds/{run_id}/publish/reconcile", + "status": 200, + "example": "Confirmed", + "current": { + "run_id": "air-quality-20260815", + "state": "succeeded", + "reconciled": true, + "fingerprint": "9f2c1d7a4b6e8035" + }, + "with_additive_fields": { + "run_id": "air-quality-20260815", + "state": "succeeded", + "reconciled": true, + "fingerprint": "9f2c1d7a4b6e8035", + "future_optional_field": "added by a later minor contract version" + }, + "additive_paths": [ + "$" + ], + "required_type_broken": { + "run_id": 12345, + "state": "succeeded", + "reconciled": true, + "fingerprint": "9f2c1d7a4b6e8035" + }, + "broken_path": "$.run_id" + }, + { + "operation_id": "reconcilePublish", + "method": "POST", + "path": "/builds/{run_id}/publish/reconcile", + "status": 200, + "example": "AbsentReset", + "current": { + "run_id": "air-quality-20260815", + "state": "reset", + "reconciled": true, + "retry_allowed": true, + "fingerprint": "9f2c1d7a4b6e8035" + }, + "with_additive_fields": { + "run_id": "air-quality-20260815", + "state": "reset", + "reconciled": true, + "retry_allowed": true, + "fingerprint": "9f2c1d7a4b6e8035", + "future_optional_field": "added by a later minor contract version" + }, + "additive_paths": [ + "$" + ], + "required_type_broken": { + "run_id": 12345, + "state": "reset", + "reconciled": true, + "retry_allowed": true, + "fingerprint": "9f2c1d7a4b6e8035" + }, + "broken_path": "$.run_id" + }, + { + "operation_id": "getPublishAudit", + "method": "GET", + "path": "/builds/{run_id}/publish/audit", + "status": 200, + "example": "OneReset", + "current": { + "run_id": "air-quality-20260815", + "entries": [ + { + "fingerprint": "9f2c1d7a4b6e8035", + "action": "manual_reset", + "actor": "operator", + "recorded_at": "2026-08-15T03:12:45+00:00" + } + ] + }, + "with_additive_fields": { + "run_id": "air-quality-20260815", + "entries": [ + { + "fingerprint": "9f2c1d7a4b6e8035", + "action": "manual_reset", + "actor": "operator", + "recorded_at": "2026-08-15T03:12:45+00:00", + "future_optional_field": "added by a later minor contract version" + } + ], + "future_optional_field": "added by a later minor contract version" + }, + "additive_paths": [ + "$.entries[0]", + "$" + ], + "required_type_broken": { + "run_id": 12345, + "entries": [ + { + "fingerprint": "9f2c1d7a4b6e8035", + "action": "manual_reset", + "actor": "operator", + "recorded_at": "2026-08-15T03:12:45+00:00" + } + ] + }, + "broken_path": "$.run_id" + }, { "operation_id": "publishBuild", "method": "POST", @@ -5205,6 +5384,126 @@ }, "broken_path": "$.error" }, + { + "operation_id": "getPublishReceipt", + "method": "GET", + "path": "/builds/{run_id}/publish/receipt", + "status": 404, + "example": "ReceiptNotFound", + "current": { + "error": "publish receipt not found", + "code": "receipt_not_found" + }, + "with_additive_fields": { + "error": "publish receipt not found", + "code": "receipt_not_found", + "future_optional_field": "added by a later minor contract version" + }, + "additive_paths": [ + "$" + ], + "required_type_broken": { + "error": 12345, + "code": "receipt_not_found" + }, + "broken_path": "$.error" + }, + { + "operation_id": "resetPublishReceipt", + "method": "DELETE", + "path": "/builds/{run_id}/publish/receipt", + "status": 404, + "example": "ReceiptNotFound", + "current": { + "error": "publish receipt not found", + "code": "receipt_not_found" + }, + "with_additive_fields": { + "error": "publish receipt not found", + "code": "receipt_not_found", + "future_optional_field": "added by a later minor contract version" + }, + "additive_paths": [ + "$" + ], + "required_type_broken": { + "error": 12345, + "code": "receipt_not_found" + }, + "broken_path": "$.error" + }, + { + "operation_id": "resetPublishReceipt", + "method": "DELETE", + "path": "/builds/{run_id}/publish/receipt", + "status": 503, + "example": "ReconcileUnavailable", + "current": { + "error": "receipt reset could not be persisted; nothing was changed", + "code": "reconcile_unavailable" + }, + "with_additive_fields": { + "error": "receipt reset could not be persisted; nothing was changed", + "code": "reconcile_unavailable", + "future_optional_field": "added by a later minor contract version" + }, + "additive_paths": [ + "$" + ], + "required_type_broken": { + "error": 12345, + "code": "reconcile_unavailable" + }, + "broken_path": "$.error" + }, + { + "operation_id": "reconcilePublish", + "method": "POST", + "path": "/builds/{run_id}/publish/reconcile", + "status": 404, + "example": "ReceiptNotFound", + "current": { + "error": "publish receipt not found", + "code": "receipt_not_found" + }, + "with_additive_fields": { + "error": "publish receipt not found", + "code": "receipt_not_found", + "future_optional_field": "added by a later minor contract version" + }, + "additive_paths": [ + "$" + ], + "required_type_broken": { + "error": 12345, + "code": "receipt_not_found" + }, + "broken_path": "$.error" + }, + { + "operation_id": "reconcilePublish", + "method": "POST", + "path": "/builds/{run_id}/publish/reconcile", + "status": 503, + "example": "ReconcileUnavailable", + "current": { + "error": "remote state could not be determined; nothing was changed", + "code": "reconcile_unavailable" + }, + "with_additive_fields": { + "error": "remote state could not be determined; nothing was changed", + "code": "reconcile_unavailable", + "future_optional_field": "added by a later minor contract version" + }, + "additive_paths": [ + "$" + ], + "required_type_broken": { + "error": 12345, + "code": "reconcile_unavailable" + }, + "broken_path": "$.error" + }, { "operation_id": "publishBuild", "method": "POST", diff --git a/src/kpubdata_builder/service/app.py b/src/kpubdata_builder/service/app.py index 2808af38..a9086326 100644 --- a/src/kpubdata_builder/service/app.py +++ b/src/kpubdata_builder/service/app.py @@ -404,7 +404,9 @@ def _enforce_ownership() -> bool: # overload 503 (server_overloaded) (#1000, additive). # 1.79.0 -> 1.80.0: the X-Provider-Key parameter, the shared 429 auth_throttled and # overload 503 responses and the X-Request-ID header are declared (#994, description). -API_CONTRACT_VERSION = "1.80.0" +# 1.80.0 -> 1.81.0: the publish recovery routes (receipt GET/DELETE, reconcile, audit) are +# declared in the contract; they were answered since 1.19.0/1.20.0 (#994, additive). +API_CONTRACT_VERSION = "1.81.0" #: manifest status vocabulary (ok/failed/cancelled) → publish status vocabulary diff --git a/tests/unit/test_service_contract.py b/tests/unit/test_service_contract.py index a5ac2172..dbf14486 100644 --- a/tests/unit/test_service_contract.py +++ b/tests/unit/test_service_contract.py @@ -99,6 +99,10 @@ ("/builds/{run_id}/events", "GET"): "getBuildEvents", ("/builds/{run_id}/publish/readiness", "GET"): "getPublishReadiness", ("/builds/{run_id}/publish", "POST"): "publishBuild", + ("/builds/{run_id}/publish/receipt", "GET"): "getPublishReceipt", + ("/builds/{run_id}/publish/receipt", "DELETE"): "resetPublishReceipt", + ("/builds/{run_id}/publish/reconcile", "POST"): "reconcilePublish", + ("/builds/{run_id}/publish/audit", "GET"): "getPublishAudit", ("/monitoring/summary", "GET"): "getMonitoringSummary", ("/monitoring/builds", "GET"): "getMonitoringBuilds", ("/uploads", "POST"): "createUpload", @@ -161,6 +165,10 @@ ("/builds/{run_id}/events", "get"), ("/builds/{run_id}/publish/readiness", "get"), ("/builds/{run_id}/publish", "post"), + ("/builds/{run_id}/publish/receipt", "get"), + ("/builds/{run_id}/publish/receipt", "delete"), + ("/builds/{run_id}/publish/reconcile", "post"), + ("/builds/{run_id}/publish/audit", "get"), ("/monitoring/summary", "get"), ("/monitoring/builds", "get"), ("/uploads", "post"), @@ -487,6 +495,10 @@ def test_preview_request_schema_declares_bounded_limit_and_sample_mode() -> None "getBuildEvents", "getPublishReadiness", "publishBuild", + "getPublishReceipt", + "resetPublishReceipt", + "reconcilePublish", + "getPublishAudit", "getMonitoringSummary", "getMonitoringBuilds", "createUpload", @@ -738,6 +750,12 @@ def test_planned_operations_excluded_from_implementation_check() -> None: "getBuildEvents": {200, 400, 403, 404}, "getPublishReadiness": {200, 400, 403, 404}, "publishBuild": {200, 400, 403, 404, 409, 502}, + # Publish recovery (#551, #563), declared in 1.81.0 (#994). 503: the remote could not + # be read, or the outcome could not be persisted — nothing changed. + "getPublishReceipt": {200, 400, 403, 404}, + "resetPublishReceipt": {200, 400, 403, 404, 503}, + "reconcilePublish": {200, 400, 403, 404, 503}, + "getPublishAudit": {200, 400, 403, 404}, "getMonitoringSummary": {200}, "getMonitoringBuilds": {200, 400}, "createUpload": {200, 400, 403, 413}, diff --git a/tests/unit/test_service_publish.py b/tests/unit/test_service_publish.py index 4cf38ace..0804a16d 100644 --- a/tests/unit/test_service_publish.py +++ b/tests/unit/test_service_publish.py @@ -1474,6 +1474,155 @@ def test_reconcile_succeeded_records_owner_run_audit(self, tmp_path, monkeypatch assert "reconcile_succeeded" in actions +class TestPublishRecoveryConformance: + """The publish recovery routes answer what the contract declares (#994). + + The four routes were in the contract's prose only until 1.77.0, so nothing compared + their bodies with a schema. Each test sends a real request and checks the status is + declared and the body fits. + """ + + _RECEIPT = "/builds/{run_id}/publish/receipt" + _RECONCILE = "/builds/{run_id}/publish/reconcile" + _AUDIT = "/builds/{run_id}/publish/audit" + _QUERY = "target=huggingface&destination=kpubdata%2Fair-quality" + _BODY: dict[str, JsonValue] = {"target": "huggingface", "destination": "kpubdata/air-quality"} + + def _unknown( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, run_id: str + ) -> BuilderService: + return TestReceiptReconcile()._unknown_receipt_service(tmp_path, monkeypatch, run_id) + + def test_an_unknown_receipt(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + service = self._unknown(tmp_path, monkeypatch, "rc-unknown") + + resp = dispatch( + service, "GET", "/builds/rc-unknown/publish/receipt", None, query=self._QUERY + ) + + assert resp.status_code == 200 + _assert_conforms(resp, self._RECEIPT, "GET") + + def test_a_succeeded_receipt_carries_its_result( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + _with_credentials(monkeypatch, "huggingface") + monkeypatch.setitem( + publish_api_module.PUBLISHER_REGISTRY, "huggingface", _SpyPublisher("huggingface") + ) + service = _service(tmp_path) + _build(service, "rc-done", LICENSED_SPEC_YAML) + assert _publish(service, "rc-done").status_code == 200 + + receipt = dispatch( + service, "GET", "/builds/rc-done/publish/receipt", None, query=self._QUERY + ) + reconciled = dispatch(service, "POST", "/builds/rc-done/publish/reconcile", self._BODY) + + assert receipt.status_code == 200 + assert receipt.body["state"] == "succeeded" + assert "result" in receipt.body + _assert_conforms(receipt, self._RECEIPT, "GET") + assert reconciled.status_code == 200 + assert reconciled.body["reconciled"] is False + _assert_conforms(reconciled, self._RECONCILE, "POST") + + def test_a_missing_receipt(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + _with_credentials(monkeypatch, "huggingface") + service = _service(tmp_path) + _build(service, "rc-none", LICENSED_SPEC_YAML) + + for method, path, template, body, query in ( + ("GET", "/builds/rc-none/publish/receipt", self._RECEIPT, None, self._QUERY), + ("DELETE", "/builds/rc-none/publish/receipt", self._RECEIPT, None, self._QUERY), + ("POST", "/builds/rc-none/publish/reconcile", self._RECONCILE, self._BODY, ""), + ): + resp = dispatch(service, method, path, body, query=query) + + assert resp.status_code == 404 + assert resp.body["code"] == "receipt_not_found" + _assert_conforms(resp, template, method) + + def test_a_missing_query_parameter(self, tmp_path: Path) -> None: + service = _service(tmp_path) + + for method in ("GET", "DELETE"): + resp = dispatch( + service, method, "/builds/rc-bad/publish/receipt", None, query="target=huggingface" + ) + + assert resp.status_code == 400 + _assert_conforms(resp, self._RECEIPT, method) + + @pytest.mark.parametrize( + ("remote", "status", "state"), + [(True, 200, "succeeded"), (False, 200, "reset"), (None, 503, None)], + ids=["remote-exists", "remote-absent", "remote-unreadable"], + ) + def test_a_reconcile( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + remote: bool | None, + status: int, + state: str | None, + ) -> None: + service = self._unknown(tmp_path, monkeypatch, "rc-reconcile") + monkeypatch.setattr( + service._publish_api, "_probe_remote_publish_target", lambda *_args: remote + ) + + resp = dispatch(service, "POST", "/builds/rc-reconcile/publish/reconcile", self._BODY) + + assert resp.status_code == status + assert resp.body.get("state") == state + _assert_conforms(resp, self._RECONCILE, "POST") + + def test_a_reconcile_with_an_unknown_field( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + service = self._unknown(tmp_path, monkeypatch, "rc-field") + + resp = dispatch( + service, + "POST", + "/builds/rc-field/publish/reconcile", + {**self._BODY, "options": {}}, + ) + + assert resp.status_code == 400 + _assert_conforms(resp, self._RECONCILE, "POST") + + def test_a_reset_and_the_history_it_leaves( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + service = self._unknown(tmp_path, monkeypatch, "rc-reset") + + reset = dispatch( + service, "DELETE", "/builds/rc-reset/publish/receipt", None, query=self._QUERY + ) + audit = dispatch(service, "GET", "/builds/rc-reset/publish/audit", None) + + assert reset.status_code == 200 + _assert_conforms(reset, self._RECEIPT, "DELETE") + assert audit.status_code == 200 + assert [entry["action"] for entry in audit.body["entries"]] == ["manual_reset"] + _assert_conforms(audit, self._AUDIT, "GET") + + def test_a_body_the_contract_does_not_describe_fails_the_check( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Negative: the conformance check is not vacuous for these routes.""" + service = self._unknown(tmp_path, monkeypatch, "rc-negative") + resp = dispatch( + service, "GET", "/builds/rc-negative/publish/receipt", None, query=self._QUERY + ) + broken = ServiceResponse(200, {**resp.body, "state": "lost"}) + + with pytest.raises(AssertionError, match="drifts from contract"): + _assert_conforms(broken, self._RECEIPT, "GET") + + class TestOpenApiConformance: """dispatch() wire OpenAPI (ADR-0005 )."""