rcf-lite 0.25.0 → 0.26.0
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/CHANGELOG.md +25 -0
- package/blueprints/application-account-settings/docs/topics.md +1 -1
- package/blueprints/application-admin-console/docs/topics.md +1 -1
- package/blueprints/application-api-rest/docs/topics.md +1 -1
- package/blueprints/application-charts/docs/topics.md +1 -1
- package/blueprints/application-dashboard/docs/topics.md +1 -1
- package/blueprints/application-datatable/docs/topics.md +1 -1
- package/blueprints/application-empty-error-states/docs/topics.md +1 -1
- package/blueprints/application-error-handling/docs/topics.md +1 -1
- package/blueprints/application-file-upload/docs/topics.md +1 -1
- package/blueprints/application-forms-wizard/docs/topics.md +1 -1
- package/blueprints/application-notifications-in-app/docs/topics.md +1 -1
- package/blueprints/application-onboarding-tour/docs/topics.md +1 -1
- package/blueprints/application-spa/docs/topics.md +1 -1
- package/blueprints/delivery-ci-workflows/docs/topics.md +1 -1
- package/blueprints/deploy-cloudflare-workers/docs/topics.md +1 -1
- package/blueprints/deploy-hetzner-server/CHANGELOG.md +21 -0
- package/blueprints/deploy-hetzner-server/blueprint.json +1 -1
- package/blueprints/deploy-hetzner-server/contributions/probes/cloud-init-render-lint.mjs +12 -5
- package/blueprints/deploy-hetzner-server/contributions/probes/hcloud-dry-run-mock.mjs +68 -20
- package/blueprints/deploy-hetzner-server/contributions/probes/manifest-schema-validate.mjs +8 -6
- package/blueprints/deploy-hetzner-server/contributions/probes/probe-utils.mjs +7 -1
- package/blueprints/deploy-hetzner-server/contributions/probes/real-account-cloud-init-hardened.mjs +2 -1
- package/blueprints/deploy-hetzner-server/contributions/templates/cloud-init.yaml.tmpl +26 -6
- package/blueprints/deploy-hetzner-server/docs/topics.md +1 -1
- package/blueprints/edge-cloudflare-access/docs/topics.md +1 -1
- package/blueprints/edge-cloudflare-rate-limiting/docs/topics.md +1 -1
- package/blueprints/edge-cloudflare-tunnel/CHANGELOG.md +10 -0
- package/blueprints/edge-cloudflare-tunnel/blueprint.json +1 -1
- package/blueprints/edge-cloudflare-tunnel/contributions/probes/manifest-schema-validate.mjs +4 -1
- package/blueprints/edge-cloudflare-tunnel/contributions/probes/real-account-connector-healthy.mjs +110 -21
- package/blueprints/edge-cloudflare-tunnel/contributions/probes/real-account-tunnel-hostname-routes.mjs +123 -30
- package/blueprints/edge-cloudflare-tunnel/docs/topics.md +1 -1
- package/blueprints/edge-cloudflare-turnstile/docs/topics.md +1 -1
- package/blueprints/email-smtp-resend/docs/topics.md +1 -1
- package/blueprints/jobs-background/docs/topics.md +1 -1
- package/blueprints/messaging-queue-cloudflare/CHANGELOG.md +19 -0
- package/blueprints/messaging-queue-cloudflare/README.md +2 -2
- package/blueprints/messaging-queue-cloudflare/blueprint.json +1 -1
- package/blueprints/messaging-queue-cloudflare/contributions/probes/real-account-concurrency-smoke.mjs +192 -37
- package/blueprints/messaging-queue-cloudflare/docs/topics.md +1 -1
- package/blueprints/object-storage-s3/CHANGELOG.md +11 -0
- package/blueprints/object-storage-s3/README.md +14 -1
- package/blueprints/object-storage-s3/blueprint.json +12 -1
- package/blueprints/object-storage-s3/contributions/adrs/adr-2905-object-storage-s3-hetzner-object-storage-provider.json +30 -0
- package/blueprints/object-storage-s3/contributions/probes/hetzner-object-storage-round-trip.mjs +136 -0
- package/blueprints/object-storage-s3/contributions/probes/run-hetzner-object-storage-round-trip.mjs +11 -0
- package/blueprints/object-storage-s3/contributions/requirements/object-storage-s3-req-101.json +18 -0
- package/blueprints/object-storage-s3/contributions/tacs/tac-2904-object-storage-s3-hetzner-endpoint-helper.json +34 -0
- package/blueprints/object-storage-s3/contributions/user-stories/object-storage-s3-us-28110.json +25 -0
- package/blueprints/object-storage-s3/docs/topics.md +1 -1
- package/blueprints/object-storage-s3/guide/object-storage-s3.md +36 -0
- package/blueprints/observability-essentials/docs/topics.md +1 -1
- package/blueprints/observability-logging/docs/topics.md +1 -1
- package/blueprints/observability-probe-endpoints/docs/topics.md +1 -1
- package/blueprints/persistence-data-d1/docs/topics.md +1 -1
- package/blueprints/persistence-data-postgres/docs/topics.md +1 -1
- package/blueprints/persistence-data-sqlite/docs/topics.md +1 -1
- package/blueprints/platform-cloudflare-cron-triggers/docs/topics.md +1 -1
- package/blueprints/platform-cloudflare-durable-objects/CHANGELOG.md +15 -0
- package/blueprints/platform-cloudflare-durable-objects/blueprint.json +1 -1
- package/blueprints/platform-cloudflare-durable-objects/contributions/probes/namespace-facade-ready.mjs +3 -3
- package/blueprints/platform-cloudflare-durable-objects/contributions/probes/probe-utils.mjs +28 -14
- package/blueprints/platform-cloudflare-durable-objects/contributions/probes/real-account-storage-smoke.mjs +132 -26
- package/blueprints/platform-cloudflare-durable-objects/contributions/probes/sole-reader-scan.mjs +40 -39
- package/blueprints/platform-cloudflare-durable-objects/contributions/probes/storage-round-trip.mjs +26 -16
- package/blueprints/platform-cloudflare-durable-objects/contributions/probes/websocket-hub-broadcast.mjs +31 -34
- package/blueprints/platform-cloudflare-durable-objects/docs/topics.md +1 -1
- package/blueprints/platform-cloudflare-kv/CHANGELOG.md +40 -1
- package/blueprints/platform-cloudflare-kv/README.md +41 -13
- package/blueprints/platform-cloudflare-kv/blueprint.json +1 -1
- package/blueprints/platform-cloudflare-kv/contributions/probes/cache-aside-hit-then-miss.mjs +26 -26
- package/blueprints/platform-cloudflare-kv/contributions/probes/event-secrecy.mjs +49 -31
- package/blueprints/platform-cloudflare-kv/contributions/probes/facade-round-trip.mjs +58 -16
- package/blueprints/platform-cloudflare-kv/contributions/probes/list-with-prefix.mjs +6 -5
- package/blueprints/platform-cloudflare-kv/contributions/probes/real-account-eventual-consistency-smoke.mjs +131 -67
- package/blueprints/platform-cloudflare-kv/docs/topics.md +1 -1
- package/blueprints/platform-docker-compose-host/docs/topics.md +1 -1
- package/blueprints/security-auth-clerk/docs/topics.md +1 -1
- package/blueprints/security-auth-keycloak/docs/topics.md +1 -1
- package/blueprints/security-auth-magic-link/docs/topics.md +1 -1
- package/blueprints/security-auth-oauth2/docs/topics.md +1 -1
- package/blueprints/security-secrets-management/docs/topics.md +1 -1
- package/fixtures/canary-manifest.json +9 -9
- package/guidance/harness-template.md +67 -0
- package/guidance/managed/agent-instructions-block.hash +1 -1
- package/guidance/managed/agent-instructions-block.md +67 -0
- package/package.json +2 -2
- package/rcf/code-nodes/cn-500.json +12 -0
- package/rcf/code-nodes/cn-510.json +12 -0
- package/rcf/code-nodes/cn-520.json +14 -0
- package/rcf/code-nodes/cn-521.json +12 -0
- package/rcf/code-nodes/cn-522.json +13 -0
- package/rcf/code-nodes/cn-523.json +12 -0
- package/rcf/code-nodes/cn-524.json +14 -0
- package/rcf/code-nodes/cn-525.json +12 -0
- package/rcf/code-nodes/cn-526.json +14 -0
- package/rcf/fbs/fbs-160.json +15 -0
- package/rcf/fbs/fbs-165.json +15 -0
- package/rcf/fbs/fbs-170.json +16 -0
- package/rcf/fbs/fbs-171.json +16 -0
- package/rcf/fbs/fbs-172.json +15 -0
- package/rcf/fbs/fbs-173.json +17 -0
- package/rcf/fbs/fbs-174.json +17 -0
- package/rcf/requirements/req-140.json +149 -0
- package/rcf/requirements/req-145.json +53 -0
- package/rcf/requirements/req-150.json +20 -0
- package/rcf/requirements/req-151.json +85 -0
- package/rcf/requirements/req-152.json +20 -0
- package/rcf/requirements/req-153.json +59 -0
- package/rcf/requirements/req-154.json +44 -0
- package/rcf/test-suites/ts-071.json +2 -2
- package/rcf/test-suites/ts-086.json +2 -2
- package/rcf/test-suites/ts-170.json +22 -0
- package/rcf/test-suites/ts-175.json +22 -0
- package/rcf/test-suites/ts-180.json +30 -0
- package/rcf/test-suites/ts-181.json +30 -0
- package/rcf/test-suites/ts-182.json +22 -0
- package/rcf/test-suites/ts-183.json +38 -0
- package/rcf/test-suites/ts-184.json +38 -0
- package/rcf/user-stories/us-14001.json +21 -0
- package/rcf/user-stories/us-14501.json +24 -0
- package/rcf/user-stories/us-15001.json +26 -0
- package/rcf/user-stories/us-15101.json +26 -0
- package/rcf/user-stories/us-15201.json +21 -0
- package/rcf/user-stories/us-15301.json +31 -0
- package/rcf/user-stories/us-15401.json +31 -0
- package/releases/releases.yaml +11 -1
- package/src/blueprint/apply.js +64 -0
- package/src/blueprint/consistency-lint.js +432 -0
- package/src/blueprint/disposition-ledger.js +224 -0
- package/src/blueprint/dispositions.js +44 -0
- package/src/blueprint/index.js +19 -0
- package/src/browser-verify/manifest-writer.js +53 -2
- package/src/browser-verify/pack-runner.js +18 -0
- package/src/cli/blueprint.js +103 -0
- package/src/setup/identity-seed.js +18 -0
|
@@ -1,54 +1,209 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Real-account concurrency smoke.
|
|
2
|
+
* Real-account concurrency smoke for messaging-queue-cloudflare.
|
|
3
|
+
* v1.0.2.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* queue
|
|
5
|
+
* Self-provisioning: the fixture shim (packages/rcf-lite/test/fixtures/
|
|
6
|
+
* cf-platform/h2-cf-queue-real-account-shim.mjs) mints a scratch
|
|
7
|
+
* Cloudflare Queue under the throwaway prefix
|
|
8
|
+
* `h2-cf-probe-integrity-scratch-q-`, a scratch KV namespace for
|
|
9
|
+
* consumer telemetry under `h2-cf-probe-integrity-scratch-kv-tel-`,
|
|
10
|
+
* and a throwaway consumer Worker under `h2-cf-probe-integrity-
|
|
11
|
+
* scratch-w-` bound to the queue (consumer) with RCF_TEST_QUEUE
|
|
12
|
+
* (shipped queue producer binding name) + RCF_TEST_TELEMETRY_KV
|
|
13
|
+
* bindings. NO workers.dev subdomain is enabled (Dave ruling
|
|
14
|
+
* 376b4f30): the consumer is invoked BY THE QUEUE, not over HTTP.
|
|
11
15
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
16
|
+
* Driver:
|
|
17
|
+
* 1. Publishes 500 messages via the Cloudflare Queues REST publish
|
|
18
|
+
* endpoint in 10 concurrent 50-message batches
|
|
19
|
+
* (https://developers.cloudflare.com/api/operations/queue-publish-messages).
|
|
20
|
+
* 2. Polls the telemetry KV namespace via the KV REST list + get
|
|
21
|
+
* endpoints until sum(batchSize) across records >= published
|
|
22
|
+
* (drain cap 120s).
|
|
23
|
+
* 3. Computes maxConcurrent from an interval-overlap analysis on
|
|
24
|
+
* the per-invocation (start, end) timestamps the Worker's
|
|
25
|
+
* queue() handler recorded.
|
|
26
|
+
* 4. Asserts totalConsumed === published, maxConcurrent > 1, and
|
|
27
|
+
* maxConcurrent <= 250 per Cloudflare's documented push-consumer
|
|
28
|
+
* cap (https://developers.cloudflare.com/queues/platform/limits/).
|
|
29
|
+
*
|
|
30
|
+
* Local proof is a mock of Cloudflare's REST contract, not the wire.
|
|
31
|
+
* The local run exercises OUR lifecycle logic against a mock of
|
|
32
|
+
* Cloudflare's contract; the real-account gate is the only surface
|
|
33
|
+
* that proves the wire format (Dave ruling 376b4f30). This probe is
|
|
34
|
+
* therefore never described as "locally verified" - the local runs
|
|
35
|
+
* are our own lifecycle-logic proof; wire correctness is proven at
|
|
36
|
+
* the HQ real-account gate.
|
|
37
|
+
*
|
|
38
|
+
* Declared env (dispatch requirement 4):
|
|
39
|
+
* CI_HAS_CLOUDFLARE_ACCOUNT required to enter the driver path.
|
|
40
|
+
* CF_ACCOUNT_ID required (real-account only).
|
|
41
|
+
* CF_API_TOKEN required (real-account only).
|
|
42
|
+
* CF_API_BASE_URL optional test override (mock CF REST API).
|
|
43
|
+
* CF_QUEUE_MESSAGE_COUNT optional; defaults to 500.
|
|
44
|
+
*
|
|
45
|
+
* Without CI_HAS_CLOUDFLARE_ACCOUNT the probe returns pass-with-
|
|
46
|
+
* accountBoundSkipped per spec section 3.5; a pass is never reachable
|
|
47
|
+
* from credential presence alone.
|
|
15
48
|
*
|
|
16
49
|
* Anchors AC-29108-2.
|
|
17
50
|
*/
|
|
18
51
|
|
|
52
|
+
import {
|
|
53
|
+
mintScratchQueueAndWorker, destroyScratchQueueAndWorker,
|
|
54
|
+
DECLARED_ENV, QUEUE_PREFIX, WORKER_PREFIX, TELEMETRY_KV_PREFIX,
|
|
55
|
+
} from '../../../../packages/rcf-lite/test/fixtures/cf-platform/h2-cf-queue-real-account-shim.mjs';
|
|
56
|
+
import { queuePublishBatch, kvListKeys, kvGet } from '../../../../packages/rcf-lite/test/fixtures/cf-platform/h2-cf-account-api.mjs';
|
|
57
|
+
|
|
58
|
+
const DEFAULT_MESSAGE_COUNT = 500;
|
|
59
|
+
const BATCH_SIZE = 50;
|
|
60
|
+
const DRAIN_POLL_INTERVAL_MS = 500;
|
|
61
|
+
const DRAIN_TIMEOUT_MS = 120000;
|
|
62
|
+
const DOCUMENTED_PUSH_CAP = 250;
|
|
63
|
+
|
|
64
|
+
export const anchorAcId = 'AC-29108-2';
|
|
19
65
|
export const accountBound = true;
|
|
20
66
|
|
|
67
|
+
function skipResult(detail) {
|
|
68
|
+
return [{
|
|
69
|
+
anchorAcId: 'AC-29108-2',
|
|
70
|
+
verdict: 'pass',
|
|
71
|
+
detail,
|
|
72
|
+
accountBoundSkipped: true,
|
|
73
|
+
envDeclared: Array.from(DECLARED_ENV),
|
|
74
|
+
throwawayPrefixes: { queue: QUEUE_PREFIX, worker: WORKER_PREFIX, telemetryKv: TELEMETRY_KV_PREFIX },
|
|
75
|
+
}];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Interval-overlap analysis: given records [{start, end, batchSize, ...}]
|
|
79
|
+
// compute the peak simultaneous in-flight invocation count using a
|
|
80
|
+
// sweep line over the (start, +1) / (end, -1) event list.
|
|
81
|
+
function computeMaxConcurrent(records) {
|
|
82
|
+
const events = [];
|
|
83
|
+
for (const r of records) {
|
|
84
|
+
events.push([r.start, +1]);
|
|
85
|
+
events.push([r.end, -1]);
|
|
86
|
+
}
|
|
87
|
+
// Sort by time; process starts before ends at the same instant so a
|
|
88
|
+
// batch that finishes exactly when another begins does not
|
|
89
|
+
// artificially deflate the concurrency count.
|
|
90
|
+
events.sort((a, b) => (a[0] - b[0]) || (b[1] - a[1]));
|
|
91
|
+
let cur = 0, max = 0;
|
|
92
|
+
for (const [, delta] of events) {
|
|
93
|
+
cur += delta;
|
|
94
|
+
if (cur > max) max = cur;
|
|
95
|
+
}
|
|
96
|
+
return max;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function readTelemetryRecords({ namespaceId }) {
|
|
100
|
+
const keys = await kvListKeys({ namespaceId, prefix: 'telemetry-', limit: 1000 });
|
|
101
|
+
const records = await Promise.all(keys.map(async (k) => {
|
|
102
|
+
const g = await kvGet({ namespaceId, key: k.name });
|
|
103
|
+
if (!g.ok) return null;
|
|
104
|
+
try { return JSON.parse(g.text); } catch (_err) { return null; }
|
|
105
|
+
}));
|
|
106
|
+
return records.filter((r) => r && typeof r.start === 'number' && typeof r.end === 'number' && typeof r.batchSize === 'number');
|
|
107
|
+
}
|
|
108
|
+
|
|
21
109
|
export default async function runProbe() {
|
|
22
110
|
const hasAccount = process.env.CI_HAS_CLOUDFLARE_ACCOUNT === '1' || process.env.CI_HAS_CLOUDFLARE_ACCOUNT === 'true';
|
|
23
111
|
if (!hasAccount) {
|
|
112
|
+
return skipResult(`accountBoundSkipped: CI_HAS_CLOUDFLARE_ACCOUNT unset; a real-account run requires CI_HAS_CLOUDFLARE_ACCOUNT=true plus CF_ACCOUNT_ID and CF_API_TOKEN. The fixture mints its own throwaway Queue + consumer Worker + telemetry KV namespace under the H-2 prefixes; publishes via the CF Queues REST publish endpoint (no workers.dev subdomain enabled); reads telemetry via the KV REST list + get endpoints. Cap under test: ${DOCUMENTED_PUSH_CAP} concurrent invocations per push-consumer per https://developers.cloudflare.com/queues/platform/limits/. Message count: ${DEFAULT_MESSAGE_COUNT}.`);
|
|
113
|
+
}
|
|
114
|
+
if (!process.env.CF_ACCOUNT_ID || !process.env.CF_API_TOKEN) {
|
|
24
115
|
return [{
|
|
25
116
|
anchorAcId: 'AC-29108-2',
|
|
26
|
-
verdict: '
|
|
27
|
-
detail:
|
|
28
|
-
|
|
117
|
+
verdict: 'fail',
|
|
118
|
+
detail: `CI_HAS_CLOUDFLARE_ACCOUNT=true but one of CF_ACCOUNT_ID / CF_API_TOKEN is missing: accountIdPresent=${!!process.env.CF_ACCOUNT_ID} tokenPresent=${!!process.env.CF_API_TOKEN}.`,
|
|
119
|
+
envDeclared: Array.from(DECLARED_ENV),
|
|
29
120
|
}];
|
|
30
121
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
122
|
+
|
|
123
|
+
const messageCount = Number.parseInt(process.env.CF_QUEUE_MESSAGE_COUNT ?? '', 10) || DEFAULT_MESSAGE_COUNT;
|
|
124
|
+
const runId = process.env.GITHUB_RUN_ID || `local-${Date.now()}`;
|
|
125
|
+
|
|
126
|
+
let mint = null;
|
|
127
|
+
try {
|
|
128
|
+
mint = await mintScratchQueueAndWorker({ runId });
|
|
129
|
+
} catch (err) {
|
|
130
|
+
return [{
|
|
131
|
+
anchorAcId: 'AC-29108-2',
|
|
132
|
+
verdict: 'fail',
|
|
133
|
+
detail: `mintScratchQueueAndWorker failed: ${err.message}`,
|
|
134
|
+
envDeclared: Array.from(DECLARED_ENV),
|
|
135
|
+
}];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
const started = Date.now();
|
|
140
|
+
const batches = [];
|
|
141
|
+
for (let i = 0; i < messageCount; i += BATCH_SIZE) {
|
|
142
|
+
const slice = [];
|
|
143
|
+
for (let j = 0; j < BATCH_SIZE && i + j < messageCount; j++) {
|
|
144
|
+
const seq = i + j;
|
|
145
|
+
slice.push({ body: { seq, ts: new Date().toISOString(), payload: `h2-smoke-${seq}` } });
|
|
146
|
+
}
|
|
147
|
+
batches.push(slice);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const publishResults = await Promise.all(batches.map((slice) =>
|
|
151
|
+
queuePublishBatch({ queueId: mint.queue.id, messages: slice })
|
|
152
|
+
.then((r) => ({ ok: true, ...r }))
|
|
153
|
+
.catch((err) => ({ ok: false, error: err.message, status: err.status || 0 })),
|
|
154
|
+
));
|
|
155
|
+
const publishFail = publishResults.find((r) => !r.ok);
|
|
156
|
+
if (publishFail) {
|
|
157
|
+
return [{
|
|
158
|
+
anchorAcId: 'AC-29108-2',
|
|
159
|
+
verdict: 'fail',
|
|
160
|
+
detail: `queuePublishBatch failed: status=${publishFail.status} error=${publishFail.error}`,
|
|
161
|
+
envDeclared: Array.from(DECLARED_ENV),
|
|
162
|
+
}];
|
|
163
|
+
}
|
|
164
|
+
const publishedCount = publishResults.reduce((acc, r) => acc + (r.count ?? 0), 0);
|
|
165
|
+
|
|
166
|
+
let records = [];
|
|
167
|
+
let totalConsumed = 0;
|
|
168
|
+
const drainStart = Date.now();
|
|
169
|
+
while (Date.now() - drainStart < DRAIN_TIMEOUT_MS) {
|
|
170
|
+
records = await readTelemetryRecords({ namespaceId: mint.telemetryKv.id }).catch(() => []);
|
|
171
|
+
totalConsumed = records.reduce((a, r) => a + r.batchSize, 0);
|
|
172
|
+
if (totalConsumed >= publishedCount) break;
|
|
173
|
+
await new Promise((r) => setTimeout(r, DRAIN_POLL_INTERVAL_MS));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const elapsed = Date.now() - started;
|
|
177
|
+
const batchesConsumed = records.length;
|
|
178
|
+
const maxConcurrent = computeMaxConcurrent(records);
|
|
179
|
+
const drained = totalConsumed >= publishedCount;
|
|
180
|
+
const withinCap = maxConcurrent <= DOCUMENTED_PUSH_CAP;
|
|
181
|
+
const observedConcurrency = maxConcurrent > 1;
|
|
182
|
+
const allOk = drained && withinCap && observedConcurrency;
|
|
183
|
+
|
|
184
|
+
return [{
|
|
185
|
+
anchorAcId: 'AC-29108-2',
|
|
186
|
+
verdict: allOk ? 'pass' : 'fail',
|
|
187
|
+
detail: allOk
|
|
188
|
+
? `queue=${mint.queue.name} (id=${mint.queue.id}) consumerWorker=${mint.worker.name} telemetryKv=${mint.telemetryKv.id}: published ${publishedCount} messages via ${batches.length} REST publish batches; consumer telemetry drawn from ${batchesConsumed} KV records in ${elapsed}ms elapsed: totalConsumed=${totalConsumed} batches=${batchesConsumed} maxConcurrent=${maxConcurrent} (>1 observed, <= ${DOCUMENTED_PUSH_CAP} documented push cap per https://developers.cloudflare.com/queues/platform/limits/); teardown removed worker, queue and telemetry KV namespace on exit.`
|
|
189
|
+
: `queue=${mint.queue.name} publishedCount=${publishedCount} totalConsumed=${totalConsumed} maxConcurrent=${maxConcurrent} batches=${batchesConsumed} elapsed=${elapsed}ms; drained=${drained} withinCap=${withinCap} observedConcurrency=${observedConcurrency}. Documented push cap: ${DOCUMENTED_PUSH_CAP} (https://developers.cloudflare.com/queues/platform/limits/).`,
|
|
190
|
+
queueName: mint.queue.name,
|
|
191
|
+
queueId: mint.queue.id,
|
|
192
|
+
workerName: mint.worker.name,
|
|
193
|
+
telemetryKvId: mint.telemetryKv.id,
|
|
194
|
+
publishedCount,
|
|
195
|
+
totalConsumed,
|
|
196
|
+
batches: batchesConsumed,
|
|
197
|
+
maxConcurrent,
|
|
198
|
+
elapsedMs: elapsed,
|
|
199
|
+
envDeclared: Array.from(DECLARED_ENV),
|
|
200
|
+
throwawayPrefixes: { queue: QUEUE_PREFIX, worker: WORKER_PREFIX, telemetryKv: TELEMETRY_KV_PREFIX },
|
|
201
|
+
}];
|
|
202
|
+
} finally {
|
|
203
|
+
try {
|
|
204
|
+
await destroyScratchQueueAndWorker(mint);
|
|
205
|
+
} catch (err) {
|
|
206
|
+
process.stderr.write(`h2-cf-queue probe: teardown failed for queue=${mint && mint.queue && mint.queue.id} worker=${mint && mint.worker && mint.worker.name} telemetryKv=${mint && mint.telemetryKv && mint.telemetryKv.id}: ${err.message}; sweepOrphans will collect on next run.\n`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
54
209
|
}
|
|
@@ -43,7 +43,7 @@ This table is maintained shelf-wide across every blueprint's `docs/topics.md`. R
|
|
|
43
43
|
| application-empty-error-states | 22101-22899 | 23xx | shipped v1.0.0 | none |
|
|
44
44
|
| application-file-upload | 23101-23899 | 24xx | shipped v1.0.0 | none |
|
|
45
45
|
| persistence-data-postgres | 27101-27899 | 28xx | shipped v1.0.0 | `persistenceStore`, `migrationDiscipline` |
|
|
46
|
-
| object-storage-s3 | 28101-28899 | 29xx | shipped v1.
|
|
46
|
+
| object-storage-s3 | 28101-28899 | 29xx | shipped v1.1.0 | `objectStorageContract` |
|
|
47
47
|
| messaging-queue-cloudflare | 29101-29899 | 30xx | shipped v1.0.0 | `deliverySemantics` |
|
|
48
48
|
| jobs-background | 30101-30899 | 31xx | shipped v1.0.0 | `backgroundJobModel` |
|
|
49
49
|
| platform-cloudflare-kv | 31101-31899 | 32xx | shipped v1.0.0 | `keyValueStoreContract` |
|
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# object-storage-s3 changelog
|
|
2
2
|
|
|
3
|
+
## 1.1.0 - 2026-09-08
|
|
4
|
+
|
|
5
|
+
Follow-up adapter minor bump adding the Hetzner Object Storage provider value under the shipped v1.0.0 S3-API facade contract. Landed via the hetzner-round-7-spec-2026-09-07 section 5.4 follow-up (ratified 2026-09-07T10:15Z on Baz's 09:50Z ruling "round 7 - agreee to all") as a sequential PR post round 7. Additive only: every existing v1.0.0 contribution id is byte-identical; the shipped Cloudflare R2 real-account smoke, the MinIO local emulator round-trip and the five shipped Node-only probes continue to pass byte-identical on the extended fixture. rcf-lite package version does NOT bump for this follow-up (spec section 8).
|
|
6
|
+
|
|
7
|
+
- 4 delta contributions on the blueprint side (25 total, up from 21): 1 REQ (`object-storage-s3-REQ-101`, endpoint-shape contract), 1 US (`object-storage-s3-US-28110` with `AC-28110-1` labelled `AC-hetznerObjectStorage-endpointRoundTrip`), 1 TAC (`TAC-2904-object-storage-s3-hetzner-endpoint-helper`, sole composer of `<bucket>.<location>.your-objectstorage.com`), 1 ADR (`ADR-2905-object-storage-s3-hetzner-object-storage-provider`, scope local, `standardsTraceClause: Hetzner Object Storage S3 compatibility documented endpoint shape`).
|
|
8
|
+
- One new Node-only probe `hetzner-object-storage-round-trip.mjs` under `contributions/probes/`, `accountBound: true`, gated on `CI_HAS_HETZNER_OBJECT_STORAGE` alongside `HETZNER_OBJECT_STORAGE_ACCESS_KEY_ID`, `HETZNER_OBJECT_STORAGE_SECRET_ACCESS_KEY`, `HETZNER_OBJECT_STORAGE_BUCKET`, `HETZNER_OBJECT_STORAGE_LOCATION`; without the gate env var the probe records `accountBoundSkipped: true` per spec section 3.5. The round-5 T-2 R2 real-account smoke is unaffected and stays gated on `CI_HAS_CLOUDFLARE_ACCOUNT`.
|
|
9
|
+
- Extended the shared `packages/rcf-lite/test/fixtures/infra-s3-and-queue/` fixture (no second fixture directory) with `src/hetzner-endpoint.mjs` (endpoint helper plus two fixture-side mutation switches per the 2026-09-08 mutation-purity discipline: `SIMULATE_HETZNER_ENDPOINT_MISSHAPEN` drops the vendor subdomain, `SIMULATE_HETZNER_EVENT_LEAK` appends fixture-labelled credential fields to event records) and a fixture-root shim `run-hetzner-object-storage-round-trip.mjs`. The probe module reads no `SIMULATE_` variable.
|
|
10
|
+
- Vendor endpoint pattern and location codes verbatim from https://docs.hetzner.com/storage/object-storage/overview (`<bucket>.<location>.your-objectstorage.com`; locations `fsn1` Falkenstein, `hel1` Helsinki, `nbg1` Nuremberg). Composition helper refuses any location outside the enum.
|
|
11
|
+
- Blueprint README extended with a Hetzner Object Storage sub-section under the provider-choice section; `guide/object-storage-s3.md` extended with a Hetzner section including the when-to-reach-for guidance; shelf-wide sweep on every blueprint's `docs/topics.md` updates the object-storage-s3 row from `shipped v1.0.0` to `shipped v1.1.0` per the round-6 admin-console 1.1.0 precedent (PR #169).
|
|
12
|
+
- `capabilities: ["objectStorage"]`, `suggestedCompanions: [{role: "logging"}, {role: "errorHandling"}]`, `providesRoles` absent, `requiresAppliedCapabilities` unchanged.
|
|
13
|
+
|
|
3
14
|
## 1.0.0 - 2026-09-06
|
|
4
15
|
|
|
5
16
|
Initial release. Contract for S3-shape object storage on any S3-compatible endpoint (Cloudflare R2 first, MinIO for local dev, AWS S3, Backblaze B2, Wasabi, self-hosted). Accessed through a store facade that is the sole reader of the S3 client (`@aws-sdk/client-s3` on Node targets or `aws4fetch` on Workers targets). Landed via the infra round 5 spec (ratified 2026-09-06) as track T-2.
|
|
@@ -19,12 +19,14 @@ Object storage on the S3 API, accessed through a store facade that is the sole r
|
|
|
19
19
|
| REQ-004 | Multipart-upload above the ADR-2903 threshold (default 8 MiB); abort-on-failure with no orphan in-flight uploads. |
|
|
20
20
|
| REQ-005 | Four lifecycle events with metadata-only fields; event-secrecy probe asserts against a PII fixture. |
|
|
21
21
|
| REQ-006 | Credential-pair discipline via `security-secrets-management`; refuses composition without with stable message id `object-storage-s3-no-secrets`. |
|
|
22
|
+
| REQ-101 (v1.1.0) | Hetzner Object Storage adapter: endpoint-shape helper composes `<bucket>.<location>.your-objectstorage.com` from an elicited bucket and location; MinIO and R2 paths unchanged. |
|
|
22
23
|
|
|
23
24
|
## The three TACs
|
|
24
25
|
|
|
25
26
|
- **TAC-2901 facade**: the object-storage S3 facade module.
|
|
26
27
|
- **TAC-2902 multipart uploader**: initiate/part-upload/complete with abort-on-failure.
|
|
27
28
|
- **TAC-2903 event sink**: metadata-only lifecycle event contract.
|
|
29
|
+
- **TAC-2904 Hetzner endpoint helper (v1.1.0)**: sole composer of `<bucket>.<location>.your-objectstorage.com`; enum-refuses unknown location codes.
|
|
28
30
|
|
|
29
31
|
## The four ADRs
|
|
30
32
|
|
|
@@ -32,10 +34,19 @@ Object storage on the S3 API, accessed through a store facade that is the sole r
|
|
|
32
34
|
- **ADR-2902 presigned URL TTL**: default 15 minutes, floor 1 minute; elicited ceiling. Standards trace clause: generic enterprise practice.
|
|
33
35
|
- **ADR-2903 multipart threshold**: default 8 MiB, elicited. Standards trace clause: generic enterprise practice.
|
|
34
36
|
- **ADR-2904 object storage contract** (scope: global, topic `objectStorageContract`): S3 API as the shipped shape; conflicts by design with future non-S3-shape siblings. Standards trace clause: AWS S3 API and R2 S3-compatibility notes.
|
|
37
|
+
- **ADR-2905 Hetzner Object Storage provider (v1.1.0)**: recognises `hetznerObjectStorage` as a shipped provider value under the shipped S3-API facade; endpoint pattern `<bucket>.<location>.your-objectstorage.com` composed via TAC-2904; MinIO and R2 paths unchanged. Standards trace clause: Hetzner Object Storage S3 compatibility documented endpoint shape.
|
|
35
38
|
|
|
36
39
|
## Elicited parameters
|
|
37
40
|
|
|
38
|
-
Endpoint URL (R2 `https://<account-id>.r2.cloudflarestorage.com`, AWS S3 `https://s3.<region>.amazonaws.com`, MinIO `http://localhost:9000`, any S3-compatible remote); bucket name; credential-pair reference via `security-secrets-management` (never value); adapter (`awsSdk` or `aws4fetch`); presigned-URL default TTL (default 15 minutes); presigned-URL ceiling (elicited, no shipped default, S3 API 7-day outer bound); multipart-upload threshold (default 8 MiB); force path style (`true` for MinIO, `false` for AWS/R2 with virtual-hosted style); server-side encryption default (`sseNone` on R2 per the R2 compatibility notes; `sseS3` on AWS S3 when the operator elicits it).
|
|
41
|
+
Endpoint URL (R2 `https://<account-id>.r2.cloudflarestorage.com`, AWS S3 `https://s3.<region>.amazonaws.com`, MinIO `http://localhost:9000`, Hetzner Object Storage `https://<bucket>.<location>.your-objectstorage.com` composed from a bucket and location via the v1.1.0 helper, any S3-compatible remote); bucket name; credential-pair reference via `security-secrets-management` (never value); adapter (`awsSdk` or `aws4fetch`); presigned-URL default TTL (default 15 minutes); presigned-URL ceiling (elicited, no shipped default, S3 API 7-day outer bound); multipart-upload threshold (default 8 MiB); force path style (`true` for MinIO, `false` for AWS/R2 and Hetzner Object Storage with virtual-hosted style); server-side encryption default (`sseNone` on R2 per the R2 compatibility notes; `sseS3` on AWS S3 when the operator elicits it).
|
|
42
|
+
|
|
43
|
+
## Hetzner Object Storage adapter (v1.1.0)
|
|
44
|
+
|
|
45
|
+
Since v1.1.0 the blueprint recognises `hetznerObjectStorage` as a shipped provider value under the existing S3-API facade contract. Hetzner Object Storage is S3-compatible per https://docs.hetzner.com/storage/object-storage/overview; the endpoint follows the vendor-documented pattern `<bucket>.<location>.your-objectstorage.com`. Three location codes are shipped: `fsn1` (Falkenstein), `hel1` (Helsinki), `nbg1` (Nuremberg), matching the same page. A project applying the blueprint with the Hetzner provider elicits the bucket and location; a small helper (`composeHetznerEndpoint`, TAC-2904) composes the https endpoint URL and hands it to the shipped facade unchanged. `region` defaults to `auto` (matching the R2 shape); `forcePathStyle` defaults to `false` (virtual-hosted-style, matching R2 and AWS S3). The Cloudflare R2 real-account smoke stays gated on `CI_HAS_CLOUDFLARE_ACCOUNT` and does not read the new env vars; the MinIO local emulator flow stays byte-identical. Reach for Hetzner over R2 for a European storage region with a different egress-pricing profile; reach for R2 for the zero-egress-fee posture; reach for MinIO for local dev without any account.
|
|
46
|
+
|
|
47
|
+
Runtime dependency: none added. The shipped `@aws-sdk/client-s3` client signs and speaks the S3 wire protocol against the composed Hetzner endpoint unchanged.
|
|
48
|
+
|
|
49
|
+
A new Node-only probe `hetzner-object-storage-round-trip.mjs` binds AC-28110-1: with `CI_HAS_HETZNER_OBJECT_STORAGE` set (plus `HETZNER_OBJECT_STORAGE_ACCESS_KEY_ID`, `HETZNER_OBJECT_STORAGE_SECRET_ACCESS_KEY`, `HETZNER_OBJECT_STORAGE_BUCKET`, `HETZNER_OBJECT_STORAGE_LOCATION`) it round-trips a byte-equal 1 KiB payload against a real Hetzner Object Storage bucket; without the env var it records `accountBoundSkipped: true` per spec section 3.5.
|
|
39
50
|
|
|
40
51
|
## Companions
|
|
41
52
|
|
|
@@ -58,6 +69,7 @@ Each probe is a Node module under `contributions/probes/` exporting the round-5
|
|
|
58
69
|
| `multipart-upload` | AC-28104-1, AC-28104-2 | 10 MiB payload above the 8 MiB threshold round-trips byte-equal via multipart; no orphan in-flight uploads after complete; abort-on-failure with `SIMULATE_PART_UPLOAD_FAIL=true`. | false |
|
|
59
70
|
| `event-secrecy` | AC-28105-1 | Every event record carries only whitelisted metadata fields; no PII fixture text; no forbidden field name. | false |
|
|
60
71
|
| `r2-real-account-smoke` | AC-28108-1 | Real R2 round-trip when `CI_HAS_CLOUDFLARE_ACCOUNT` is set; records `accountBoundSkipped: true` when it is not. | true |
|
|
72
|
+
| `hetzner-object-storage-round-trip` (v1.1.0) | AC-28110-1 | Composes `<bucket>.<location>.your-objectstorage.com` via `composeHetznerEndpoint`; real Hetzner Object Storage round-trip when `CI_HAS_HETZNER_OBJECT_STORAGE` is set; records `accountBoundSkipped: true` when it is not. | true |
|
|
61
73
|
|
|
62
74
|
## How to run the probes locally
|
|
63
75
|
|
|
@@ -73,6 +85,7 @@ node ../../../../blueprints/object-storage-s3/contributions/probes/run-presigned
|
|
|
73
85
|
node ../../../../blueprints/object-storage-s3/contributions/probes/run-multipart-upload.mjs
|
|
74
86
|
node ../../../../blueprints/object-storage-s3/contributions/probes/run-event-secrecy.mjs
|
|
75
87
|
node ../../../../blueprints/object-storage-s3/contributions/probes/run-r2-real-account-smoke.mjs
|
|
88
|
+
node ../../../../blueprints/object-storage-s3/contributions/probes/run-hetzner-object-storage-round-trip.mjs
|
|
76
89
|
docker compose down -v
|
|
77
90
|
```
|
|
78
91
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"slug": "object-storage-s3",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"category": "object-storage",
|
|
5
5
|
"capabilities": ["objectStorage"],
|
|
6
6
|
"requiresAppliedCapabilities": {
|
|
@@ -67,6 +67,17 @@
|
|
|
67
67
|
"scope": "global",
|
|
68
68
|
"topic": "objectStorageContract",
|
|
69
69
|
"standardsTraceClause": "AWS S3 API and Cloudflare R2 S3-compatibility notes"
|
|
70
|
+
},
|
|
71
|
+
{ "id": "object-storage-s3-REQ-101", "kind": "req", "path": "requirements/object-storage-s3-req-101.json" },
|
|
72
|
+
{ "id": "object-storage-s3-US-28110", "kind": "us", "path": "user-stories/object-storage-s3-us-28110.json" },
|
|
73
|
+
{ "id": "TAC-2904-object-storage-s3-hetzner-endpoint-helper", "kind": "tac", "path": "tacs/tac-2904-object-storage-s3-hetzner-endpoint-helper.json" },
|
|
74
|
+
{
|
|
75
|
+
"id": "ADR-2905-object-storage-s3-hetzner-object-storage-provider",
|
|
76
|
+
"kind": "adr",
|
|
77
|
+
"path": "adrs/adr-2905-object-storage-s3-hetzner-object-storage-provider.json",
|
|
78
|
+
"recommendedDefault": false,
|
|
79
|
+
"elicited": true,
|
|
80
|
+
"standardsTraceClause": "Hetzner Object Storage S3 compatibility documented endpoint shape"
|
|
70
81
|
}
|
|
71
82
|
]
|
|
72
83
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"adrId": "ADR-2905-object-storage-s3-hetzner-object-storage-provider",
|
|
3
|
+
"prdId": "PRD-001",
|
|
4
|
+
"tadId": "TAD-001",
|
|
5
|
+
"version": "1.1.0",
|
|
6
|
+
"status": "accepted",
|
|
7
|
+
"title": "Hetzner Object Storage as a shipped provider value under the object-storage-s3 v1.0.0 facade; endpoint pattern <bucket>.<location>.your-objectstorage.com per the Hetzner storage overview; MinIO and R2 paths unchanged",
|
|
8
|
+
"context": "The object-storage-s3 v1.0.0 blueprint shipped Cloudflare R2 (production first-target on the shipped adapter shape), AWS S3 (interop first-class through the same wire protocol), MinIO (local emulator for dev), and any other S3-compatible endpoint (Backblaze B2 in S3-compat mode, Wasabi, self-hosted) as providers a project can point at by pasting an endpoint URL into the elicited endpointUrl parameter on the facade. Hetzner Object Storage is S3-compatible per https://docs.hetzner.com/storage/object-storage/overview (fetched 2026-09-08 200) and reads through the shipped facade without a client change, but its endpoint URL is derived from two elicited values (a bucket name and a location code in {fsn1, hel1, nbg1}) rather than pasted in whole; the vendor pattern is <bucket>.<location>.your-objectstorage.com per the same page. A follow-up minor bump adds the Hetzner Object Storage provider value by shipping an endpoint-shape helper (TAC-2904) that composes the vendor pattern from those two elicited values and hands the composed https URL to the facade unchanged. No topic conflict is raised because the objectStorageContract topic (ADR-2904, scope global) names the wire-shape contract as the S3 API, and Hetzner Object Storage speaks that contract per the overview.",
|
|
9
|
+
"decision": "The shipped v1.1.0 object-storage-s3 blueprint recognises hetznerObjectStorage as a shipped provider value alongside R2, AWS, MinIO. A project applying object-storage-s3 with the Hetzner provider elicits a bucket name and a location code in {fsn1, hel1, nbg1}, and the shipped endpoint-shape helper (TAC-2904, composeHetznerEndpoint) composes https://<bucket>.<location>.your-objectstorage.com verbatim per https://docs.hetzner.com/storage/object-storage/overview. The composed URL feeds the shipped facade (TAC-2901) through the existing endpointUrl parameter; no facade shape moves, no runtime dependency lands in the applying project, no re-signing surface is added (the S3 client's SigV4 signing works unchanged against the composed endpoint). Region defaults to auto (matching the R2 shape); forcePathStyle defaults to false (matching R2 and AWS S3 with virtual-hosted-style buckets). A new Node-only probe hetzner-object-storage-round-trip.mjs binds to AC-28110-1: with CI_HAS_HETZNER_OBJECT_STORAGE the probe round-trips a byte-equal 1 KiB payload against a real bucket, without it the probe records accountBoundSkipped true per spec section 3.5. The MinIO local emulator and R2 real-account smoke stay byte-identical and keep their v1.0.0 acceptance shape.",
|
|
10
|
+
"consequences": "A project that outgrows the MinIO local emulator or wants a European storage location with lower egress costs than R2 or AWS S3 picks hetznerObjectStorage at apply time and the shipped facade routes through the composed endpoint unchanged. Standards trace clause: Hetzner Object Storage S3 compatibility documented endpoint shape (https://docs.hetzner.com/storage/object-storage/overview) enumerates the endpoint pattern and the three location codes fsn1, hel1, nbg1. The provider list is not exhaustive; a future v1.2.0 minor could add another S3-compatible provider under the same shape by shipping another endpoint helper. The shipped R2 real-account smoke stays gated on CI_HAS_CLOUDFLARE_ACCOUNT and does not read CI_HAS_HETZNER_OBJECT_STORAGE; the two smokes skip independently. rcf-lite package version does NOT bump for this follow-up (spec section 8): the blueprint bumps 1.0.0 -> 1.1.0 and ships as its own sequential PR post-round-7.",
|
|
11
|
+
"alternativesConsidered": [
|
|
12
|
+
{
|
|
13
|
+
"name": "Extend ADR-2901 alternativesConsidered instead of shipping a sibling ADR",
|
|
14
|
+
"summary": "Mutate the shipped v1.0.0 ADR-2901 body to enumerate Hetzner Object Storage as a fourth alternative under the SDK-adapter axis.",
|
|
15
|
+
"reasonNotChosen": "ADR-2901's alternativesConsidered axis is client-choice (AWS SDK v3 vs aws4fetch vs hand-rolled SigV4), not provider-choice. Hetzner Object Storage is a provider under the shipped AWS SDK v3 adapter; mutating ADR-2901 would mis-scope the alternative. The additive-only discipline of a minor bump prefers a sibling ADR that references ADR-2901 by id over a mutation of the shipped body."
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"name": "Ship the Hetzner Object Storage endpoint as a documented example on the freeform endpointUrl only, without a helper module",
|
|
19
|
+
"summary": "Document the endpoint pattern in the guide and let the applying project paste https://<bucket>.<location>.your-objectstorage.com into endpointUrl by hand.",
|
|
20
|
+
"reasonNotChosen": "A hand-composed endpoint is a shape a project can mis-key silently (a missing dot before your-objectstorage.com, a bucket-shaped hostname prefix that a linter cannot flag). The endpoint-shape helper turns the vendor convention into a mechanical composition with an enum-checked location code and a probe that asserts the composed value against the vendor pattern. The helper is 20 lines of code and lives inside the applying project; the trade-off is one small module for a mechanically-checked correctness gain."
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"name": "Mint a separate object-storage-hetzner blueprint slug",
|
|
24
|
+
"summary": "Ship a fresh blueprint object-storage-hetzner v1.0.0 with the Hetzner-shaped facade instead of extending object-storage-s3.",
|
|
25
|
+
"reasonNotChosen": "Hetzner Object Storage speaks the S3 wire protocol per the vendor overview; a separate slug would duplicate the whole object-storage-s3 facade contract and split the shelf on a provider axis rather than a contract axis. The objectStorageContract global-ADR topic already names the wire-shape contract as the S3 API; adding a provider value under that contract stays additive."
|
|
26
|
+
}
|
|
27
|
+
],
|
|
28
|
+
"createdAt": "2026-09-08T00:00:00.000Z",
|
|
29
|
+
"updatedAt": "2026-09-08T00:00:00.000Z"
|
|
30
|
+
}
|
package/blueprints/object-storage-s3/contributions/probes/hetzner-object-storage-round-trip.mjs
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hetzner Object Storage round-trip probe.
|
|
3
|
+
*
|
|
4
|
+
* Composes the vendor-documented endpoint pattern
|
|
5
|
+
* <bucket>.<location>.your-objectstorage.com through the fixture-side
|
|
6
|
+
* hetzner-endpoint helper (TAC-2904; sole composer of the pattern),
|
|
7
|
+
* opens the shipped v1.0.0 facade (TAC-2901 over @aws-sdk/client-s3
|
|
8
|
+
* per ADR-2901) against the composed endpoint, puts a 1 KiB payload,
|
|
9
|
+
* gets it back byte-equal, deletes the scratch object, and asserts
|
|
10
|
+
* every lifecycle event record carries only whitelisted metadata
|
|
11
|
+
* fields per the fixture-side whitelist.
|
|
12
|
+
*
|
|
13
|
+
* accountBound: true. Skipped in CI without CI_HAS_HETZNER_OBJECT_STORAGE
|
|
14
|
+
* per hetzner-round-7-spec-2026-09-07 section 3.5 and section 5.4.
|
|
15
|
+
*
|
|
16
|
+
* Dependency-load discipline: the fixture-side facade module
|
|
17
|
+
* `object-store.mjs` imports `@aws-sdk/client-s3`, which is a
|
|
18
|
+
* fixture-scoped dependency installed by `npm install` inside the
|
|
19
|
+
* fixture directory (documented in the fixture README). To keep the
|
|
20
|
+
* accountBoundSkipped path loadable without that fixture install
|
|
21
|
+
* (surface inspection under `node --test`, tools that walk the probe
|
|
22
|
+
* module for its `accountBound` flag), the SDK-touching imports are
|
|
23
|
+
* DYNAMIC and gated on the CI_HAS_HETZNER_OBJECT_STORAGE env var.
|
|
24
|
+
*
|
|
25
|
+
* Mutation-purity discipline (per HQ hard gate row 2026-09-08): this
|
|
26
|
+
* module reads NO SIMULATE_ variable. Every fixture-side mutation
|
|
27
|
+
* hook (SIMULATE_HETZNER_ENDPOINT_MISSHAPEN,
|
|
28
|
+
* SIMULATE_HETZNER_EVENT_LEAK) lives in
|
|
29
|
+
* packages/rcf-lite/test/fixtures/infra-s3-and-queue/src/hetzner-endpoint.mjs
|
|
30
|
+
* and alters the INPUT the probe assembles (the composed URL or the
|
|
31
|
+
* event-record payload).
|
|
32
|
+
*
|
|
33
|
+
* Anchors AC-28110-1 (label AC-hetznerObjectStorage-endpointRoundTrip).
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { probeKey } from './probe-utils.mjs';
|
|
37
|
+
|
|
38
|
+
export const accountBound = true;
|
|
39
|
+
|
|
40
|
+
const VENDOR_PATTERN = /^https:\/\/[^./]+\.(fsn1|hel1|nbg1)\.your-objectstorage\.com$/;
|
|
41
|
+
|
|
42
|
+
export default async function runProbe() {
|
|
43
|
+
if (!process.env.CI_HAS_HETZNER_OBJECT_STORAGE) {
|
|
44
|
+
return [{
|
|
45
|
+
anchorAcId: 'AC-28110-1',
|
|
46
|
+
verdict: 'pass',
|
|
47
|
+
detail: 'accountBound: skipped (no CI_HAS_HETZNER_OBJECT_STORAGE)',
|
|
48
|
+
accountBoundSkipped: true,
|
|
49
|
+
}];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Dynamic imports so the accountBoundSkipped path above loads
|
|
53
|
+
// without the fixture-scoped @aws-sdk/client-s3 dependency.
|
|
54
|
+
const {
|
|
55
|
+
composeHetznerEndpoint,
|
|
56
|
+
hetznerCredentialsFromEnv,
|
|
57
|
+
makeHetznerEventDecorator,
|
|
58
|
+
assertMetadataOnlyEventRecords,
|
|
59
|
+
HETZNER_EVENT_WHITELIST,
|
|
60
|
+
} = await import('../../../../packages/rcf-lite/test/fixtures/infra-s3-and-queue/src/hetzner-endpoint.mjs');
|
|
61
|
+
const { createObjectStore } = await import('../../../../packages/rcf-lite/test/fixtures/infra-s3-and-queue/src/object-store.mjs');
|
|
62
|
+
|
|
63
|
+
const { accessKeyId, secretAccessKey, bucket, location } = hetznerCredentialsFromEnv();
|
|
64
|
+
if (!accessKeyId || !secretAccessKey || !bucket || !location) {
|
|
65
|
+
return [{
|
|
66
|
+
anchorAcId: 'AC-28110-1',
|
|
67
|
+
verdict: 'fail',
|
|
68
|
+
detail: 'CI_HAS_HETZNER_OBJECT_STORAGE set but one or more required env vars missing (HETZNER_OBJECT_STORAGE_ACCESS_KEY_ID, HETZNER_OBJECT_STORAGE_SECRET_ACCESS_KEY, HETZNER_OBJECT_STORAGE_BUCKET, HETZNER_OBJECT_STORAGE_LOCATION)',
|
|
69
|
+
}];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const results = [];
|
|
73
|
+
let endpoint;
|
|
74
|
+
try {
|
|
75
|
+
endpoint = composeHetznerEndpoint({ bucket, location });
|
|
76
|
+
} catch (err) {
|
|
77
|
+
return [{
|
|
78
|
+
anchorAcId: 'AC-28110-1',
|
|
79
|
+
verdict: 'fail',
|
|
80
|
+
detail: `endpoint composition threw on field ${err.field || 'unknown'}: ${err.message}`,
|
|
81
|
+
}];
|
|
82
|
+
}
|
|
83
|
+
if (!VENDOR_PATTERN.test(endpoint)) {
|
|
84
|
+
return [{
|
|
85
|
+
anchorAcId: 'AC-28110-1',
|
|
86
|
+
verdict: 'fail',
|
|
87
|
+
detail: `composed endpoint does not match vendor pattern <bucket>.<location>.your-objectstorage.com; got ${endpoint}; missing subdomain: your-objectstorage.com`,
|
|
88
|
+
}];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const events = [];
|
|
92
|
+
const store = createObjectStore({
|
|
93
|
+
endpointUrl: endpoint,
|
|
94
|
+
bucket,
|
|
95
|
+
credentialsRef: { accessKeyId, secretAccessKey },
|
|
96
|
+
region: 'auto',
|
|
97
|
+
forcePathStyle: false,
|
|
98
|
+
onEvent: (e) => events.push(makeHetznerEventDecorator({
|
|
99
|
+
event: e.event,
|
|
100
|
+
ts: e.ts,
|
|
101
|
+
endpointHost: e.endpointHost,
|
|
102
|
+
bucketName: e.bucketName,
|
|
103
|
+
location,
|
|
104
|
+
key: e.key,
|
|
105
|
+
size: e.size,
|
|
106
|
+
contentType: e.contentType,
|
|
107
|
+
})),
|
|
108
|
+
});
|
|
109
|
+
const key = probeKey('probe/hetzner-smoke');
|
|
110
|
+
const body = Buffer.alloc(1024, 0x48);
|
|
111
|
+
try {
|
|
112
|
+
await store.ready();
|
|
113
|
+
await store.putObject(key, 'application/octet-stream', body);
|
|
114
|
+
const got = await store.getObject(key);
|
|
115
|
+
const roundTripEqual = got.body.length === 1024 && got.body.equals(body);
|
|
116
|
+
results.push({
|
|
117
|
+
anchorAcId: 'AC-28110-1',
|
|
118
|
+
verdict: roundTripEqual ? 'pass' : 'fail',
|
|
119
|
+
detail: roundTripEqual
|
|
120
|
+
? `Hetzner Object Storage round-trip byte-equal against ${endpoint} bucket=${bucket} location=${location}`
|
|
121
|
+
: `Hetzner Object Storage round-trip failed byte equality; expected 1024 bytes got ${got.body.length}`,
|
|
122
|
+
});
|
|
123
|
+
const evAssertion = assertMetadataOnlyEventRecords(events);
|
|
124
|
+
results.push({
|
|
125
|
+
anchorAcId: 'AC-28110-1',
|
|
126
|
+
verdict: evAssertion.pass ? 'pass' : 'fail',
|
|
127
|
+
detail: evAssertion.pass
|
|
128
|
+
? `every lifecycle event carries only whitelisted fields (${[...HETZNER_EVENT_WHITELIST].join(',')})`
|
|
129
|
+
: `event-secrecy leak: forbidden fields present ${evAssertion.leaked.join(',')}`,
|
|
130
|
+
});
|
|
131
|
+
} finally {
|
|
132
|
+
try { await store.deleteObject(key); } catch { /* teardown best effort */ }
|
|
133
|
+
await store.close();
|
|
134
|
+
}
|
|
135
|
+
return results;
|
|
136
|
+
}
|
package/blueprints/object-storage-s3/contributions/probes/run-hetzner-object-storage-round-trip.mjs
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shim: run hetzner-object-storage-round-trip against the extended
|
|
3
|
+
* infra-s3-and-queue fixture and write
|
|
4
|
+
* .rcf/reports/blueprints/object-storage-s3/hetzner-object-storage-round-trip.json.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import runProbe from './hetzner-object-storage-round-trip.mjs';
|
|
8
|
+
import { runShim } from './probe-utils.mjs';
|
|
9
|
+
|
|
10
|
+
const engine = { kind: 's3', image: 'hetzner-object-storage-cloud', healthy: true };
|
|
11
|
+
runShim('hetzner-object-storage-round-trip', engine, runProbe);
|
package/blueprints/object-storage-s3/contributions/requirements/object-storage-s3-req-101.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"reqId": "object-storage-s3-REQ-101",
|
|
3
|
+
"prdId": "PRD-001",
|
|
4
|
+
"title": "Hetzner Object Storage adapter: endpoint-shape helper composes <bucket>.<location>.your-objectstorage.com from an elicited bucket and location code",
|
|
5
|
+
"description": "The object-storage-s3 v1.1.0 minor bump adds a Hetzner Object Storage provider value under the shipped S3-API facade contract without changing the outward facade shape. The Hetzner Object Storage endpoint follows the vendor-documented pattern <bucket>.<location>.your-objectstorage.com per https://docs.hetzner.com/storage/object-storage/overview; a project applying the blueprint elicits a bucket name and a location code (fsn1 for Falkenstein, hel1 for Helsinki, nbg1 for Nuremberg per the same overview page) and the adapter composes the https endpoint URL through a single helper module. The helper is the sole composer of the vendor pattern for this provider so a project cannot accidentally construct an invalid endpoint by hand-concatenating the parts; the composed endpoint feeds the shipped S3 client (@aws-sdk/client-s3 on Node targets per ADR-2901) unchanged. The v1.0.0 Cloudflare R2, AWS S3 and MinIO paths stay byte-identical: the endpoint URL parameter on the facade remains freeform, and only projects that elicit hetznerObjectStorage as the provider consume the helper.",
|
|
6
|
+
"category": "functional",
|
|
7
|
+
"domain": "object-storage",
|
|
8
|
+
"priority": "must",
|
|
9
|
+
"rationale": "A single helper module for the Hetzner Object Storage endpoint pattern turns a documented vendor convention into a mechanical composition that the applied project cannot mis-shape by editing a config file. Reading the endpoint pattern from bucket plus location, in code, makes the composition observable to the probe (which asserts the composed value against the vendor pattern) and gives the mutation-purity switch a fixture-side seam (SIMULATE_HETZNER_ENDPOINT_MISSHAPEN alters the composed bucket segment on the INPUT side; the probe module never reads a SIMULATE_ variable and the composition module always composes correctly). The Cloudflare R2, AWS S3 and MinIO providers keep their v1.0.0 shape because their endpoint URLs are already documented on their own pages and each project applying those providers pastes the endpoint URL through the elicited freeform parameter; Hetzner Object Storage differs only in that the endpoint is derived from two elicited values rather than one pasted URL.",
|
|
10
|
+
"tags": [
|
|
11
|
+
"blueprint:object-storage-s3",
|
|
12
|
+
"category:07-hetzner-object-storage-adapter"
|
|
13
|
+
],
|
|
14
|
+
"version": "1.1.0",
|
|
15
|
+
"status": "approved",
|
|
16
|
+
"createdAt": "2026-09-08T00:00:00.000Z",
|
|
17
|
+
"updatedAt": "2026-09-08T00:00:00.000Z"
|
|
18
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"tacId": "TAC-2904-object-storage-s3-hetzner-endpoint-helper",
|
|
3
|
+
"prdId": "PRD-001",
|
|
4
|
+
"tadId": "TAD-001",
|
|
5
|
+
"version": "1.1.0",
|
|
6
|
+
"status": "approved",
|
|
7
|
+
"name": "Hetzner Object Storage endpoint-shape helper",
|
|
8
|
+
"purpose": "The single project-side module that composes the Hetzner Object Storage endpoint URL from an elicited bucket name and a vendor-documented location code (fsn1, hel1, nbg1 per https://docs.hetzner.com/storage/object-storage/overview). The helper is the sole composer of the pattern <bucket>.<location>.your-objectstorage.com; every consumer that needs the endpoint imports this module and calls composeHetznerEndpoint({ bucket, location }). Downstream, the composed https URL feeds the shipped S3 client (TAC-2901 facade over @aws-sdk/client-s3 per ADR-2901) unchanged so the outward facade shape does not move for the Hetzner provider. The Cloudflare R2, AWS S3 and MinIO paths stay byte-identical: the endpoint URL parameter on the facade remains freeform and only projects that elicit hetznerObjectStorage consume this helper.",
|
|
9
|
+
"responsibilities": [
|
|
10
|
+
"Compose the endpoint URL as https://<bucket>.<location>.your-objectstorage.com from an elicited bucket and location code per the vendor overview (AC-28110-1).",
|
|
11
|
+
"Refuse composition when the location code is not one of the vendor-documented values (fsn1, hel1, nbg1) so the applying project cannot silently ship an invalid endpoint (AC-28110-1).",
|
|
12
|
+
"Expose one named export composeHetznerEndpoint that returns the composed URL string; hold no S3 client reference so a future adapter or endpoint variant slots in as a helper module swap without changing this contract (AC-28110-1).",
|
|
13
|
+
"Read no SIMULATE_ environment variable in the helper module body; INPUT-side mutation (SIMULATE_HETZNER_ENDPOINT_MISSHAPEN) lives on the fixture-side shim, keeping the helper deterministic and probe-neutral (AC-28110-1)."
|
|
14
|
+
],
|
|
15
|
+
"internalStructure": "One module exporting composeHetznerEndpoint({ bucket, location }) -> string. The function validates the bucket against the S3 bucket-name discipline the shipped facade already accepts and validates the location against the enum {fsn1, hel1, nbg1} sourced verbatim from the Hetzner Object Storage overview page. On invalid input it throws a named Error whose message names the offending field so a probe or a lint can surface it. The composed URL string is returned; no side effects, no client reference, no event emission.",
|
|
16
|
+
"interfaces": [
|
|
17
|
+
{
|
|
18
|
+
"name": "composeHetznerEndpoint",
|
|
19
|
+
"kind": "factory",
|
|
20
|
+
"description": "composeHetznerEndpoint({ bucket, location }) -> string. Returns https://<bucket>.<location>.your-objectstorage.com when bucket is a non-empty string and location is one of fsn1, hel1, nbg1; throws a named Error naming the invalid field otherwise."
|
|
21
|
+
}
|
|
22
|
+
],
|
|
23
|
+
"dependencies": [
|
|
24
|
+
{
|
|
25
|
+
"name": "Object-storage S3 facade",
|
|
26
|
+
"kind": "tac",
|
|
27
|
+
"tacId": "TAC-2901-object-storage-s3-facade",
|
|
28
|
+
"description": "The facade the composed URL feeds into as the elicited endpointUrl; the helper output is opaque at the facade boundary so the R2, AWS S3 and MinIO paths stay unchanged."
|
|
29
|
+
}
|
|
30
|
+
],
|
|
31
|
+
"tradeoffs": "The helper holds the vendor pattern in code rather than in the applying project's config file. A vendor endpoint change (a new location code or a URL-shape update) is a helper edit and a version bump on this blueprint, not a project-side scramble. The three currently documented locations are hard-coded per the overview page; a future location addition ships as a minor bump on this blueprint (v1.2.0) not a project-side workaround. A project that needs a different endpoint shape for a non-Hetzner S3-compatible endpoint uses the elicited freeform endpointUrl on the facade directly and skips this helper.",
|
|
32
|
+
"createdAt": "2026-09-08T00:00:00.000Z",
|
|
33
|
+
"updatedAt": "2026-09-08T00:00:00.000Z"
|
|
34
|
+
}
|