Skip to content

Package the native server as an opt-in release-candidate image - #803

Merged
rmcdaniel merged 9 commits into
mainfrom
rust-325-native-image
Oct 27, 2026
Merged

rmcdaniel merged 9 commits into
mainfrom
rust-325-native-image

Conversation

@rmcdaniel

@rmcdaniel rmcdaniel commented Oct 27, 2026 •

Copy link
Copy Markdown
Member

Part of #325. Design: #325 comments 6431039138 and 6431101579. Review request: 6431904291. Security design acceptance: 6431969727.

The native server gets an opt-in release-candidate image in its own repositories, durableworkflow/server-native and ghcr.io/durable-workflow/server-native. The PHP image, its Release workflow and its defaults are unchanged.

1. The image (ed7a175a6)

Dockerfile.native:

  • Build: Rust 1.99.0 on Debian 12, pinned as Security's verified multi-architecture index (rust@sha256:114c7a44…: amd64 b36c2467…, arm64 e393e07b…), runs cargo build --release --locked natively on each architecture. The build context is the rust/ crate only.
  • Runtime: digest-pinned debian@sha256:7ff6ba31… slim, holding only the binary. TLS uses rustls with compiled-in roots, and SQLite is bundled.
  • Process: runs as 1000:1000, stops on SIGTERM, exposes 8080.
  • PHP's paths: WORKDIR /app, SQLite at /app/database/database.sqlite, volumes /app/database and /app/storage. PHP records local external payload objects by absolute file:///app/storage/... path, and native's default local root is <cwd>/storage/app/external-payloads/<ns>. A taken-over installation therefore keeps its volumes at the same paths.
  • Environment: DW_RUST_MAINTENANCE_LOOP=1, since there is no separate scheduler container. DW_RUST_EXPERIMENTAL is never set, so the server still refuses to start without the explicit opt-in.
  • Takeover: the same image runs the takeover commands.

scripts/ci/native-image-smoke.sh <image> <platform> <receipt> checks:

  • the image config: user, signal, port, entrypoint, workdir, volumes and environment;
  • the refusal without the opt-in (non-zero exit);
  • a cold start on SQLite, PostgreSQL 17 and MySQL 8.0, each with /api/ready, an authenticated /api/cluster/info and a SIGTERM exit code 0;
  • that upgrade-inspect exits non-zero with upgrade_inspection_unavailable on a missing SQLite source, without creating it.

Docker resources are named from RESOURCE_SCOPE, and the workflows remove them in always-steps.

Native release candidate (native-release.yml, workflow_dispatch from main, inputs tag and release_commit):

  1. One job per architecture (ubuntu-latest, ubuntu-24.04-arm). Each job:

    • checks out trusted tooling at github.sha and the immutable tag;
    • verifies the tag names the commit;
    • builds and pushes by digest only (provenance: false);
    • smoke-tests that pushed digest on its own architecture.

    A failed smoke leaves no tag.

  2. A publish job (scripts/ci/native-release-publish.sh):

    • refuses a tag either registry already has, failing closed on any lookup error other than not-found;
    • tags both smoked digests as one index in both registries;
    • verifies both indexes have one digest and name exactly those platform digests;
    • attests the index's build provenance in both registries.

Native image (native-image.yml): pull requests that change these files build the image and run the same smoke test natively on linux/amd64 (ubuntu-latest) and linux/arm64 (ubuntu-24.04-arm). It never pushes. The smoke test's MySQL is the 8.0 multi-architecture index 7dcddc01…; its amd64 entry is the 62fb722c… image the fixtures pin.

2. The takeover runbook (docs/rust-server-upgrade-takeover.md)

A new section gives concrete commands for the image:

  1. Stop every PHP role.
  2. Inspect three times.
  3. Back up, restore into a separate database and require equal fingerprints.
  4. Take over with that fingerprint.
  5. Start native.
  6. Never restart PHP.

Every command uses one exact image, IMAGE=durableworkflow/server-native@sha256:<index digest from the release receipt>, never a tag that could move between steps (56eb1d34b). Credentials pass by name (-e DB_PASSWORD, -e DW_AUTH_TOKEN), following Security's review (7e28e5952). The section also covers:

  • volume ownership (PHP writes as www-data, native runs as 1000);
  • SQLite;
  • that sources holding external payload references stay refused.

3. The matched performance harness runs the published image (b2d62ea70, d638cc156)

The release-candidate performance gate measures the artifact operators run.

  • run-server-port-matched.sh … native-image runs the image named by digest (DW_PORT_NATIVE_IMAGE), with its own entrypoint, user and maintenance loop, under native-server's limits.
  • The profile must name artifacts.native_image, and the driver refuses any other image. This follows Security's advisory.
  • The php and native implementations are unchanged.

Review fixes

  • 050beac16: SonarCloud's findings exposed that native-image-server had been inserted in the middle of native-server's Compose block. native-server is restored to main's block. native-image-server shares its environment through a YAML anchor and checks readiness over bash's /dev/tcp. The smoke probe uses http.client.
  • be4fcbc11: Security found the builder and MySQL pins were amd64-only. Both are now multi-architecture indexes, and the PR matrix builds and smokes natively on arm64.
  • 56eb1d34b: the runbook is bound to one exact image digest.

Checks run

  • Local smoke: linux/amd64, image sha256:53837092…, from a local build of this source. It passes on all three databases. Startup logs no warnings, after DW_RUST_MAINTENANCE_LOOP was registered in Release message streams at retention, and keep PHP streams at takeover #801.
  • Policies: both organization policies pass locally, Docker workflow isolation and central qualification_policy.py --target server.
  • Harness: Compose renders the php, native and native-image profiles. The driver refuses a profile without the image and a profile naming another image.

Before first publication

  • Cloud owns the dockerhub environment's ability to push to the new repositories, and whether they must exist before a first run.
  • General runs the release.

Nothing here pushes to a registry.

🤖 Generated with Claude Code

rmcdaniel and others added 7 commits October 27, 2026 02:00
The opt-in for native's maintenance loop (#797) was missing from the
config audit's native-only names, so setting it logged "Unknown DW_
variables set in environment". The native image smoke test showed it.
The shipped-environment test now sets it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Server #325's native server has no image yet. This adds one, as a
separate repository (durableworkflow/server-native and
ghcr.io/durable-workflow/server-native); the PHP image and its Release
workflow are unchanged.

Dockerfile.native builds the binary with the Shared Server fixtures'
pinned compiler (--locked) and copies it onto a digest-pinned Debian 12
slim runtime. It runs as 1000:1000, stops on SIGTERM and listens on
8080. It uses the PHP image's paths, /app/database for SQLite and
/app/storage for local external payload objects, whose absolute file://
paths PHP records in the database. It sets DW_RUST_MAINTENANCE_LOOP=1,
because there is no separate scheduler container, and never
DW_RUST_EXPERIMENTAL: the server still refuses to start without the
explicit opt-in. The same image runs the takeover commands.

scripts/ci/native-image-smoke.sh checks:
- the image config;
- the refusal without the opt-in;
- a cold start on SQLite, PostgreSQL 17 and MySQL 8.0, with /api/ready,
  an authenticated /api/cluster/info and a clean SIGTERM exit;
- upgrade-inspect refusing a missing SQLite source without creating it.

Docker resources are named from RESOURCE_SCOPE, and the workflows remove
them in always-steps.

Native release candidate (workflow_dispatch from main, for an existing
release tag and commit):
- each architecture builds on its own runner, pushes by digest only and
  smoke-tests that pushed digest;
- the publish job refuses a tag either registry already has (failing
  closed on any lookup error);
- it tags both smoked digests as one index in both registries and checks
  that both name exactly those digests;
- it attests the index's build provenance in both registries.

Native image runs the same build and smoke test on pull requests that
change these files, without pushing.

The takeover runbook gains concrete commands for the image: stop PHP,
inspect, back up and prove the restore by equal fingerprints, take over
with that fingerprint, start native, and never restart PHP. It also
covers volume ownership and SQLite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The release-candidate performance gate measures the artifact operators
will run, not a binary mounted into the PHP image. A native-image
implementation runs the image named by digest (DW_PORT_NATIVE_IMAGE),
with its own entrypoint, user and maintenance loop and native-server's
CPU and memory limits. The driver refuses a reference without a digest
or one other than the profile's artifacts.native_image, and records it
in identity.json; the summarizer counts native-image-server as the
runtime. The php and native implementations are unchanged; the binary
mount no longer requires DW_PORT_NATIVE_BINARY when native does not run.

Checked: Compose renders the php, native and native-image profiles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Security's review of the native image draft (server#325 comment
6431969727): the start command wrote DW_AUTH_TOKEN=<token> on the command
line. It now passes the exported value by name, as DB_PASSWORD already
was, so no credential enters arguments or shell history.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Security's advisory on b2d62ea: native-image mode compared the image
only when the profile named one, so a profile without
artifacts.native_image accepted any digest-pinned image. The driver now
refuses such a profile, so every native-image result binds to the
profile's exact image.

Checked: a profile without the field and one naming another image are
refused before anything starts; the matching profile proceeds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SonarCloud flagged two new security findings on this PR, and reading the
first exposed a real defect:
- b2d62ea inserted native-image-server in the middle of native-server's
  block, so native-server lost its depends_on and curl healthcheck to the
  new service, which has no curl. native-server is back to main's block.
  native-image-server reuses its environment through a YAML anchor (no
  second copy of the disposable benchmark password, yaml:S2068), waits
  for MySQL, and checks readiness with a plain GET over bash's /dev/tcp.
- The smoke probe built an http:// URL string (shell:S5332). It now uses
  http.client with the host and port: the server answers plain HTTP on
  the job's private network. The probe's positional parameters are named
  locals.

Checked: Compose renders both services with their own depends_on and
healthcheck; the /dev/tcp check answers HTTP/1.0 200 OK against the
image; the local smoke test passes on SQLite, PostgreSQL and MySQL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@rmcdaniel

rmcdaniel commented Oct 27, 2026 •

Copy link
Copy Markdown
Member Author

Security advisory re-check of exact head 050beac:

The corrected Compose boundary is sound: native-server has its original MySQL dependency and curl readiness check again; native-image-server separately inherits only the intended disposable benchmark environment, waits for MySQL, and probes readiness without adding curl. The http.client smoke probe keeps the host and path as data, and the PR workflow has read-only contents permission with no publication credentials.

One actionable migration-safety correction remains in docs/rust-server-upgrade-takeover.md lines 68–92. Every inspection, fingerprint, irreversible takeover, and final start command uses durableworkflow/server-native:. The release workflow records a verified multi-architecture index digest, but registry tags remain mutable outside that workflow. If the tag moves between steps, the operator can inspect with one executable and mutate/start with another.

Please export one exact image reference from the release receipt, for example IMAGE=durableworkflow/server-native@sha256:, and use $IMAGE in every command through startup. That binds the executable identity across the whole no-in-place-rollback sequence.

This is a focused correction to the unreleased runbook, not a standing review gate. I found no further security correction in the Sonar follow-up itself.

@rmcdaniel

Copy link
Copy Markdown
Member Author

Additional exact-head release-path finding from read-only registry inspection:

Dockerfile.native pins the Rust builder as mirror.gcr.io/library/rust@sha256:ba81bc3eaa4422af576c0262515d96b0111a628a6ccc2c86557cf55c9a4bbee0. The registry identifies that digest as an OCI image manifest for linux/amd64, not a multi-architecture index. Its annotation also identifies it as the amd64 form of rust:1-slim-bookworm.

The pinned Debian runtime digest is correctly a multi-architecture OCI index and includes both linux/amd64 and linux/arm64/v8. The release workflow, however, runs a native linux/arm64 build on ubuntu-24.04-arm without setting up emulation. Its builder FROM therefore cannot yield the intended arm64 binary from the current amd64-only builder digest; the existing PR workflow proves only amd64.

Please pin a reviewed Rust builder index digest that contains both amd64 and arm64, or select reviewed per-architecture builder digests explicitly while preserving the same compiler/toolchain contract. Then exercise the arm64 Dockerfile build before treating the two-architecture release path as qualified.

This is a concrete multi-architecture release-path correction, not a standing security gate or a reason to block unrelated Rust source work.

rmcdaniel added a commit that referenced this pull request Oct 27, 2026
The checkpoint names the verified 2.11.4 artifacts, the Core 2.14
expectation changes native now follows, the poll_transport position and
the Workflow #931 boundary, with what was checked. The Rust port's
current state lists #799 and #801 as merged, #803 and this move as
next, and 2.11.4 as the compared reference.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@rmcdaniel

Copy link
Copy Markdown
Member Author

To reduce correction turnaround, read-only registry resolution of mirror.gcr.io/library/rust:1.99.0-bookworm produced immutable OCI index sha256:114c7a4425406451c2866b6aafe69fe29b1b298832db1277d411ac73c82d04d6, matching this head’s rust/rust-toolchain.toml channel 1.99.0. I independently inspected that digest: it contains native linux/amd64 child sha256:b36c246742b4d323472588f789601be34eb4af053255e2af53969078ee8dcec7 and linux/arm64/v8 child sha256:e393e07b3c3c39f238de676b0eb60c03dc7aefe5d6e37be6d29c686bf4d7e256. Pinning the index lets BuildKit select the correct native builder on each release runner. This supplies the concrete replacement input; the corrected head still needs an arm64 build/smoke result before relying on the release path.

rmcdaniel and others added 2 commits October 27, 2026 05:11
Security found that the pinned Rust builder (ba81bc3e…) is a single
linux/amd64 manifest, so the release's ubuntu-24.04-arm job could not
build natively (server#803 comment 6433955707). The smoke test's MySQL
pin (62fb722c…) is amd64-only as well.
- Dockerfile.native builds from Security's verified Rust 1.99.0/bookworm
  multi-architecture index 114c7a44… (amd64 b36c2467…, arm64 e393e07b…,
  comment 6434047541).
- The smoke test runs MySQL 8.0 from its index 7dcddc01…, whose amd64
  entry is the 62fb722c… image already pinned and whose arm64 entry is
  213bbfaf…. The Python probe, PostgreSQL and the Debian runtime were
  already multi-architecture indexes.
- Native image runs on pull requests as a matrix: linux/amd64 on
  ubuntu-latest and linux/arm64 on ubuntu-24.04-arm, each building and
  smoke-testing natively, with a receipt per architecture.

Checked: a local linux/amd64 build from the new builder passes the smoke
test on SQLite, PostgreSQL and MySQL; both organization policies pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Security's re-check of 050beac (server#803 comment 6433835833): every
inspection, fingerprint, the irreversible takeover and the final start
named durableworkflow/server-native:<tag>. A tag can move between steps,
so the operator could inspect with one executable and take over or start
with another. The runbook now exports one reference, the
multi-architecture index digest the release receipt records
(IMAGE=durableworkflow/server-native@sha256:...), pulls it, keeps it with
the backup and uses "$IMAGE" in every command through startup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@rmcdaniel

Copy link
Copy Markdown
Member Author

Security delta review at exact head 56eb1d34bddff96688583598b58e9e5f48b1e363: the open source findings are corrected. Dockerfile.native now pins the verified Rust 1.99/Debian 12 multi-architecture index; the MySQL 8.0 smoke dependency uses its verified multi-architecture index; PR CI defines native amd64 and arm64 build/smoke jobs on matching runners. The takeover runbook now exports one repository@sha256:<index> reference from the release receipt, pulls it once, retains it with the backup, and uses that same quoted value for every inspection, both fingerprints, takeover, and startup; no mutable native image tag remains. The docs-only final delta is clean and introduces no new finding. Source review is accepted. Final PR evidence still needs the restarted exact-head native workflow 40454456424 to complete both architectures successfully; that is ordinary exact-head CI, not a new security finding or an unrelated-work gate.

@sonarqubecloud

Copy link
Copy Markdown

@rmcdaniel

Copy link
Copy Markdown
Member Author

Security terminal verification for exact head 56eb1d34bddff96688583598b58e9e5f48b1e363: native-image run 40454456424 completed successfully. Both linux/amd64 and native linux/arm64 built, passed their image smoke tests, completed cleanup, and uploaded distinct smoke receipts (native-image-smoke-amd64, native-image-smoke-arm64). The PR head remained unchanged after completion. This satisfies the remaining Security acceptance condition for the reviewed source; merge/release ownership remains with the Rust Server owner.

@rmcdaniel

Copy link
Copy Markdown
Member Author

Native build and smoke at exact head 56eb1d34bddff96688583598b58e9e5f48b1e363 (run 40454456424, Native image workflow):

Platform Runner Job Image ID SQLite PostgreSQL 17 MySQL 8.0
linux/amd64 ubuntu-latest 7m41s sha256:75140df0… ready, cluster info, SIGTERM exit 0 same same
linux/arm64 ubuntu-24.04-arm 8m12s sha256:a043dcc3… ready, cluster info, SIGTERM exit 0 same same

Each image was built natively on its own architecture from the multi-architecture Rust index 114c7a44…, and each smoke test:

  • checked the image config;
  • refused to start without the opt-in;
  • refused upgrade-inspect on a missing SQLite source without creating it.

Each receipt is the run's native-image-smoke-<arch> artifact. SonarCloud's gate passes on this head.

@rmcdaniel

Copy link
Copy Markdown
Member Author

General release-path review accepted at exact head 56eb1d34bddff96688583598b58e9e5f48b1e363.

Reviewed the dispatch-only/main-ref publication boundary, trusted release tooling versus immutable tagged source, per-architecture digest outputs and smoke-before-tag dependency, exact two-platform index verification in both registries, non-overwriting tag checks, provenance steps, nonroot image configuration, explicit experimental opt-in, and backup/takeover commands retaining one immutable image reference. The matched-performance extension records and requires its exact image digest; its native image uses its own entrypoint and maintenance loop.

The exact-head native CI run 40454456424 passes both native amd64 and arm64 build/smoke/cleanup jobs. Required feature, fixture-result and genuine Sonar checks are green. I did not build Rust locally or run a takeover. This accepts the source and release path; it does not qualify a published native release, the broader cutover, or production deployment. Merge itself publishes no image.

@rmcdaniel
rmcdaniel added this pull request to the merge queue Oct 27, 2026
Merged via the queue into main with commit cd016d2 Oct 27, 2026
27 checks passed
@rmcdaniel

Copy link
Copy Markdown
Member Author

Merged through the merge queue at 2026-10-27T06:31:57Z, 22 minutes after enqueue (06:10:01Z, pinned to head 56eb1d34bddff96688583598b58e9e5f48b1e363). Security cleared this head (6434520818), and General accepted its release review (server-325 chat 2902).

  • Merge commit: cd016d293759b9835af183eb8bc022689951e102
  • Parents: b5f314635da573744db53e6eac384c1571f5575d (main, with Project runtime payloads before worker progress frames #806) and 56eb1d34bddff96688583598b58e9e5f48b1e363 (head)
  • Tree: c82ffd6773b1ed36d9de833038ecf90c0c937c99, identical to git merge-tree of those parents

Nothing is published yet. The native release (native-release.yml, dispatch from main) waits for Cloud's accepted Docker Hub publisher in private #450 and for General's choice of release tag. Because release.yml runs on every semver tag, a native-only tag would also start a PHP release.

@rmcdaniel
rmcdaniel deleted the rust-325-native-image branch October 27, 2026 06:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant