Skip to content

Commit 116c0d9

Browse files
os-zhuangclaude
andauthored
feat(cli,runtime): artifact-pinned boot — OS_ARTIFACT_URL with an SRI-style fragment pin (#8368) (#8526)
* feat(cli,runtime): artifact-pinned boot via OS_ARTIFACT_URL (#8368) Boot a stack from a published artifact by reference — one env var, with the optional integrity pin SRI-style inside the URL fragment (no companion OS_ARTIFACT_SHA256). Fetched (https) or read directly (file), verified, protocol-checked and materialised locally before the boot continues. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7vaLs7bhBPi9m3JyzkhDj * test(runtime,cli): pin the six acceptance criteria for artifact-pinned boot (#8368) Unit coverage for reference parsing, the fragment pin, integrity verification, the pinned-only cache fallback, the protocol handshake and secrets redaction; a fake-driver suite for the boot migration policy; and an end-to-end suite that boots `os serve` in an empty directory with no project checkout. Two assertions are written against a specific way of being wrong: the unpinned fetch-failure case plants a usable cache first (so "no cache-fallback logic" can actually fail), and every credential-absence assertion carries a positive control proving the captured text is the text that would have carried it. `refusalOf()` replaces `.catch((e) => e as ArtifactReferenceError)` so a call that wrongly succeeds fails as itself rather than on a missing property — which also keeps the runtime TEST_DEBT ledger at its recorded 227 instead of raising it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P7vaLs7bhBPi9m3JyzkhDj --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b30963d commit 116c0d9

14 files changed

Lines changed: 2320 additions & 9 deletions
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
---
2+
"@objectstack/runtime": minor
3+
"@objectstack/cli": minor
4+
---
5+
6+
feat(cli,runtime): `OS_ARTIFACT_URL` — boot a stack from a published artifact by reference (#8368)
7+
8+
`objectstack start` / `serve` can now be pointed at an artifact **by reference**
9+
with a single environment variable, so a fixed runtime image plus one env var is
10+
a running app. Upgrading the app becomes an env change and a restart rather than
11+
an image rebuild — the runtime image and the app artifact become two independent
12+
release axes.
13+
14+
```bash
15+
OS_ARTIFACT_URL=https://cdn.example.com/hotcrm-2.2.2.json # fetched at boot
16+
OS_ARTIFACT_URL=file:///srv/app/objectstack.json # read directly
17+
OS_ARTIFACT_URL='https://cdn.example.com/hotcrm-2.2.2.json#sha256=<64 hex>' # content-verified
18+
```
19+
20+
**One variable, not two.** The optional integrity pin is SRI-style and lives
21+
inside the URL **fragment**; there is deliberately no companion
22+
`OS_ARTIFACT_SHA256`. A fragment is client-side by standard and is never sent to
23+
the server, so the pin travels with the reference — one value to copy, one value
24+
to rotate — without changing anything the artifact host sees. A second variable
25+
would make "URL updated, hash not" a reachable state; this shape makes it
26+
unspellable.
27+
28+
**Precedence.** `--artifact` > `OS_ARTIFACT_URL` > `OS_ARTIFACT_PATH` >
29+
`<cwd>/dist/objectstack.json`. Beating `OS_ARTIFACT_PATH` matters in practice:
30+
the official runtime image sets it to `/srv/app/objectstack.json`, so on a
31+
container carrying no app it is always set and always points at a file that does
32+
not exist. `OS_ARTIFACT_URL` also wins over an `objectstack.config.ts` in the
33+
working directory — naming a published artifact is an explicit instruction, and
34+
a deployed app must not depend on which directory the process is standing in.
35+
36+
**What it refuses, and how loudly:**
37+
38+
- **No pin → no verification.** A fetch or read failure fails the boot loudly so
39+
container orchestration retries. There is no cache-fallback on this path: with
40+
no pin there is nothing to authenticate a cached copy with.
41+
- **Pin present → verified before boot.** A mismatch refuses and names the
42+
**expected and the actual** digest, so a republished artifact is
43+
distinguishable from a substituted one. A fetch failure may fall back to a
44+
locally cached copy, but only one whose bytes still hash to the pin — the
45+
cache is re-hashed on every read, so the filename is never the authority — and
46+
it says so with a loud warning.
47+
- **`engines.protocol` is validated against the runtime** at reference
48+
resolution, before anything connects, and an incompatible artifact refuses
49+
with both ways out named (repoint the reference, or run a matching image).
50+
- **Migration policy.** Safe migrations run at boot; a destructive change (the
51+
`os migrate apply --allow-destructive` class) refuses the boot with an
52+
operator message naming every change. Never skipped in silence. This applies
53+
to the artifact-pinned boot only — every other boot keeps the standing
54+
production policy, under which the schema is never auto-altered.
55+
56+
**Secrets.** The reference may be a pre-signed URL, i.e. the credential *is* the
57+
URL. Nothing downstream of resolution ever sees it: remote bytes are
58+
materialised to a local file under `<home>/artifacts` and the boot continues
59+
against that path, so the URL reaches neither the banner, nor the metadata
60+
service's artifact-source record, nor any log line. Every message this path
61+
produces — including messages originating inside `fetch`, which routinely carry
62+
the whole URL — is scrubbed of userinfo and query material. Userinfo is moved
63+
into an `Authorization: Basic` header, both because `fetch` refuses to construct
64+
a request from a URL carrying credentials and because a credential in the
65+
request line lands in the artifact host's access log.
66+
67+
Materialising the fetched bytes is also what makes the pin mean anything: the
68+
bytes that were hashed are the bytes that boot, and the artifact is fetched
69+
exactly once.
70+
71+
Not included, by design: `OS_PACKAGE_REF` registry resolution, signature
72+
enforcement and entitlements; multi-tenant fleet / hostname routing. Fetching
73+
and booting an artifact is open-framework mechanism — walled tenancy postures
74+
remain entitled through `@objectstack/organizations` regardless of how the
75+
artifact arrives.

content/docs/deployment/cli.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,7 @@ os start
319319
| Flag | Env equivalent | Purpose |
320320
|---|---|---|
321321
| `-a, --artifact <path\|url>` | `OS_ARTIFACT_PATH` | File path or `http(s)://` URL to the compiled artifact |
322+
|| `OS_ARTIFACT_URL` | Boot a published artifact **by reference**, optionally content-hash pinned via a `#sha256=` fragment. See [Artifact-pinned boot](/docs/deployment/self-hosting#artifact-pinned-boot-os_artifact_url) |
322323
| `-d, --database <url>` | `OS_DATABASE_URL` | `file:…` / `libsql://` / `postgres://` / `mongodb://` / `memory://` |
323324
| `--database-driver <kind>` | `OS_DATABASE_DRIVER` | Force `sqlite` \| `sqlite-wasm` \| `turso` \| `postgres` \| `mysql` \| `mongodb` \| `memory` when the URL is ambiguous |
324325
| `--database-auth-token <token>` | `OS_DATABASE_AUTH_TOKEN` | Auth token for libsql/Turso |
@@ -336,7 +337,7 @@ os start
336337
> (CORS). **Pin the port explicitly** (`OS_PORT=8080 os start`) and keep
337338
> `OS_AUTH_URL` / `OS_TRUSTED_ORIGINS` in sync when you change it.
338339
339-
**Resolution priority (artifact):** `--artifact` > `OS_ARTIFACT_PATH` > `<cwd>/dist/objectstack.json` > `<home>/dist/objectstack.json` > auto-compile from `objectstack.config.ts` (when present) > empty kernel.
340+
**Resolution priority (artifact):** `--artifact` > `OS_ARTIFACT_URL` > `OS_ARTIFACT_PATH` > `<cwd>/dist/objectstack.json` > `<home>/dist/objectstack.json` > auto-compile from `objectstack.config.ts` (when present) > empty kernel.
340341
**Resolution priority (database):** `--database` > `OS_DATABASE_URL` > `DATABASE_URL` (legacy) > `file:<home>/data/objectstack.db`.
341342

342343
<Callout type="warn">

content/docs/deployment/environment-variables.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ read at startup unless noted otherwise. Boolean variables accept `true` / `false
5555
| `OS_STORAGE_LOCAL_ROOT` | path | `./.objectstack/data/uploads` | Root directory for the local file storage adapter, relative to the process cwd (used by `os serve`'s default `storage` capability wiring). This is the same value as **Setup → Settings → File Storage → Root directory**; setting it here pins that field (it shows as locked-by-env). Renamed from `OS_STORAGE_ROOT` — see below. |
5656
| `OS_STORAGE_ROOT` | path || **Deprecated alias for `OS_STORAGE_LOCAL_ROOT`.** Still read for one release, with a startup warning; it will be removed in a future major. Rename it now. Before the rename the two halves of the platform spelled this value differently — the CLI wrote `OS_STORAGE_ROOT` while the settings service read `OS_STORAGE_LOCAL_ROOT` — so **any value other than the default was silently discarded** at startup and uploads landed in `./.objectstack/data/uploads` regardless. If you set `OS_STORAGE_ROOT` on an older release, check where your uploads actually are before assuming a backup covered them. |
5757
| `OS_ARTIFACT_PATH` | path || Path or `http(s)://` URL to a compiled `objectstack.json` artifact to boot the kernel from. |
58+
| `OS_ARTIFACT_URL` | url || Boot a **published artifact by reference**`https://…/hotcrm-2.2.2.json` (fetched at boot) or `file:///…/objectstack.json` (read directly, the volume-mount workflow). Overrides `OS_ARTIFACT_PATH` and any `objectstack.config.ts` in the working directory; `--artifact` still wins. Optionally pinned with an SRI-style fragment: `…/hotcrm-2.2.2.json#sha256=<64 hex chars>` — there is deliberately **no** companion `OS_ARTIFACT_SHA256`, because a URL fragment is client-side by standard (never sent to the server) and so travels with the reference as one value. See [Artifact-pinned boot](/docs/deployment/self-hosting#artifact-pinned-boot-os_artifact_url). |
5859

5960
---
6061

content/docs/deployment/self-hosting.mdx

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,53 @@ docker run -p 8080:8080 \
114114
(`OS_ARTIFACT_PATH` also accepts an `https://` URL, so the artifact can come
115115
straight from release storage instead of a mount.)
116116

117+
### Artifact-pinned boot (`OS_ARTIFACT_URL`)
118+
119+
The image above carries no app. `OS_ARTIFACT_URL` names one **by reference**, so
120+
a fixed runtime image plus one environment variable is a running app — and
121+
upgrading the app is an env change plus a restart, never an image rebuild. The
122+
runtime image and the app artifact become two independent release axes.
123+
124+
```bash
125+
docker run -p 8080:8080 \
126+
-e OS_ARTIFACT_URL="https://releases.example.com/hotcrm-2.2.2.json#sha256=<64 hex chars>" \
127+
-e OS_DATABASE_URL="postgres://user:pass@db-host:5432/myapp" \
128+
-e OS_AUTH_SECRET -e OS_SECRET_KEY \
129+
ghcr.io/objectstack-ai/objectstack:14.8.0
130+
```
131+
132+
Both schemes work: `https://…` is fetched at boot, `file:///…` is read directly
133+
(the volume-mount workflow above, spelled as a URL). The variable overrides the
134+
image's preset `OS_ARTIFACT_PATH` and any `objectstack.config.ts` in the working
135+
directory.
136+
137+
**The integrity pin lives in the URL fragment.** `#sha256=<64 hex chars>` is
138+
SRI-style and there is deliberately no companion `OS_ARTIFACT_SHA256`: a
139+
fragment is client-side by standard and is never sent to the server, so the pin
140+
travels with the reference as a single value to copy and a single value to
141+
rotate. Two variables would make "URL updated, hash not" a state you can reach.
142+
143+
| Situation | What the runtime does |
144+
|---|---|
145+
| No `#sha256=` fragment | Boots without verification. A fetch or read failure **fails the boot** so your orchestrator retries — there is no cache fallback, because there is nothing to authenticate a cached copy with. |
146+
| `#sha256=` present, content matches | Boots, and keeps the verified copy under `<home>/artifacts`. |
147+
| `#sha256=` present, content differs | **Refuses to boot**, naming the expected *and* the actual digest. |
148+
| `#sha256=` present, artifact host unreachable | Falls back to the cached copy **only** if it still hashes to the pin, with a loud warning that the instance is running on cached content. |
149+
| Artifact's `engines.protocol` excludes this runtime | **Refuses to boot** — the safety belt of the two-axis split. Repoint the reference, or run a matching image version. |
150+
| The artifact needs a destructive schema change | Safe migrations run at boot; a destructive one **refuses to boot** and names each change. Run `os migrate apply --allow-destructive` deliberately, then restart. Never skipped in silence. |
151+
152+
**Recommended production discipline** (convention, not enforced by the runtime):
153+
publish immutable, version-named objects; give only CI write access to the
154+
artifact host; and pin the digest in the fragment. Together these make "which
155+
bytes is this instance running?" a question with one answer.
156+
157+
**Pre-signed URLs are safe to use.** The reference may carry auth material — a
158+
signature query parameter, or `user:token@host` — and it is never echoed into
159+
logs or HTTP responses. Userinfo is sent as an `Authorization: Basic` header
160+
rather than in the request line (so it does not land in your artifact host's
161+
access log), and remote bytes are materialised to a local file before the boot
162+
continues, so the URL does not reach any downstream surface at all.
163+
117164
For a self-contained deployable image, extend it. The Dockerfile below (plus
118165
the compose stack in the next section and a `.dockerignore`) ships ready-made
119166
in the project scaffold —

docker/Dockerfile

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,19 @@
1818
# OS_ARTIFACT_PATH also accepts an https:// URL, so the artifact can be
1919
# fetched from your release storage instead of copied in.
2020
#
21+
# Or name the artifact BY REFERENCE and skip the image build entirely
22+
# (#8368) — OS_ARTIFACT_URL overrides the OS_ARTIFACT_PATH preset below, so a
23+
# container carrying no app boots the referenced one:
24+
#
25+
# docker run -p 8080:8080 \
26+
# -e OS_ARTIFACT_URL="https://releases.example.com/hotcrm-2.2.2.json#sha256=<64 hex chars>" \
27+
# -e OS_DATABASE_URL=... -e OS_AUTH_SECRET -e OS_SECRET_KEY \
28+
# ghcr.io/objectstack-ai/objectstack:<version>
29+
#
30+
# The `#sha256=` fragment is an optional SRI-style integrity pin, verified
31+
# before boot; a mismatch refuses to boot. Docs:
32+
# https://docs.objectstack.ai/docs/deployment/self-hosting#artifact-pinned-boot-os_artifact_url
33+
#
2134
# Published by .github/workflows/docker-publish.yml on every framework
2235
# release; the image tag always matches the @objectstack/cli version inside.
2336
# Docs: https://docs.objectstack.ai/docs/deployment/self-hosting

0 commit comments

Comments
 (0)