From 23c7f4e3a76b54797de872f14e1d59d63f8b39bf Mon Sep 17 00:00:00 2001 From: Yeongseon Choe Date: Sun, 4 Oct 2026 20:31:45 +0900 Subject: [PATCH] docs(contract): declare the publish recovery routes GET and DELETE /builds/{run_id}/publish/receipt, POST .../publish/reconcile and GET .../publish/audit have answered since contract 1.19.0 and 1.20.0 but were only in the contract's prose. Declare them with their schemas (1.76.0 -> 1.77.0, additive), add them to the route and status tables, and check their real responses against the schemas. Refs #994 Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + contract/builder-api.yaml | 379 +++++++++++++++++++++++++++- contract/fixtures/responses.json | 301 +++++++++++++++++++++- src/kpubdata_builder/service/app.py | 4 +- tests/unit/test_service_contract.py | 18 ++ tests/unit/test_service_publish.py | 149 +++++++++++ 6 files changed, 849 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0eca78cf..b2b3061e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ ### Added +- The publish recovery routes are in the contract (#994, API contract 1.77.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. - `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. - Saved analyses record the SQL they are written in (#875, step 14 of ADR 0021, API contract 1.76.0): `sql_dialect`, `engine`, `engine_version`, `query_contract_version` and `migration_required`. An existing analysis store gains the columns when Builder starts, and its analyses — saved for the Polars engine — are `legacy-polars`. Those are never re-run on DuckDB as if nothing had changed: `POST /analyses/{analysis_id}/run` answers 409 `analysis_migration_required` (Studio: kpubdata-lab/kpubdata-studio#565) until the SQL is reviewed and saved as a new analysis; reading and deleting them (which releases the snapshot hold) work as before. diff --git a/contract/builder-api.yaml b/contract/builder-api.yaml index 23a48165..15bb3a93 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.76.0" + version: "1.77.0" description: >- KPubData Builder(패키지 `kpubdata-builder`) 서비스 계약. BuildSpec 검증, preview, build 실행, artifact 조회, 계약 버전 조회를 제공한다. CLI와 HTTP service mode(#36)가 동일한 도메인 계약을 @@ -19,6 +19,8 @@ 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.77.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.76.0 records which SQL a saved analysis is written in (#875, additive): `SavedAnalysis` gains `sql_dialect` (`duckdb`, or `legacy-polars` for one saved before the DuckDB cutover), `engine`, `engine_version`, `query_contract_version` and `migration_required`. A legacy analysis is never re-run on DuckDB: `POST /analyses/{analysis_id}/run` answers 409 `analysis_migration_required` until its SQL is reviewed and saved as a new analysis. Existing stores gain the columns on start; their rows are the legacy ones. v1.74.0 runs SQL, rows, aggregates, profiles and exports on DuckDB (#874, ADR 0021 step 13): each in a child process over a DuckDB connection that may read only the pinned snapshot file and spill into its own directory, with no other file, network or extension access and its configuration locked. The validator now parses the DuckDB dialect and refuses introspective functions (settings, variables, versions, sequences …) and nondeterministic SQL (random values, UUIDs, the current time, `USING SAMPLE`/`TABLESAMPLE`) with `unsafe_query`. Results keep the types DuckDB gives them, in Builder's spelling, where they differ from the Polars engine: `COUNT(*)` is `int64` (was `uint32`) and an unnamed aggregate is named as DuckDB names it (`count_star()`, `sum(v)` — they were `len` or failed); a `SUM` of an integer column is `int128`, sent as a number or as exact decimal text by its values; `ORDER BY … DESC` puts nulls last (Polars put them first; rows and aggregates always did); `date + INTERVAL` now works and is a datetime; a column that is Null in Builder is `int32` (all null) in SQL results, while a page of rows still reports it Null; a zoned datetime is sent in UTC. A table with a column SQL cannot name (an empty name, or two names one letter case apart) answers `query_execution_failed` to SQL, while rows, aggregates and profiles still read it. v1.10.0은 `POST /preview`에 Source↔Silver diff와 sample_mode(first/random) @@ -1950,6 +1952,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 @@ -8947,3 +9223,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 ecb0e2c8..954aa691 100644 --- a/contract/fixtures/responses.json +++ b/contract/fixtures/responses.json @@ -1,6 +1,6 @@ { "fixture_format": 1, - "contract_version": "1.76.0", + "contract_version": "1.77.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", @@ -5181,6 +5360,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 f9478318..a2bea906 100644 --- a/src/kpubdata_builder/service/app.py +++ b/src/kpubdata_builder/service/app.py @@ -394,7 +394,9 @@ def _enforce_ownership() -> bool: # license_provider, license_mismatch and processing_declared (#955, additive). # 1.75.0 -> 1.76.0: saved analyses record their SQL dialect and engine; a legacy-polars # analysis answers 409 analysis_migration_required to run (#875, additive). -API_CONTRACT_VERSION = "1.76.0" +# 1.76.0 -> 1.77.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.77.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..f7f1c644 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.77.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 )."""