Skip to content

feat(docker): extenddb-dev — zero-config SQLite dev image - #281

Merged
LeeroyHannigan merged 14 commits into
mainfrom
feat/dev-container
Aug 19, 2026
Merged

feat(docker): extenddb-dev — zero-config SQLite dev image#281
LeeroyHannigan merged 14 commits into
mainfrom
feat/dev-container

Conversation

@LeeroyHannigan

Copy link
Copy Markdown
Collaborator

What this adds

A single-container developer image: DynamoDB-compatible endpoint over SQLite,
plain HTTP, no init step, no bootstrap sidecar, no certificate to trust.

docker run -d -p 127.0.0.1:18443:18443 -v extenddb:/var/lib/extenddb extenddb/extenddb-dev
AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY \
aws dynamodb list-tables --region us-east-1 --endpoint-url http://127.0.0.1:18443

One image serves both storage modes: file-backed on a volume (default), or
EXTENDDB__STORAGE__SQLITE__PATH=:memory: for an ephemeral instance.
Measured size: 46.1 MB uncompressed (distroless/cc runtime + a
stripped/LTO binary), against 117 MB for the Postgres image.

Contained by construction

Nothing the Postgres image builds from is edited. New files throughout:
Dockerfile.dev, docker-compose.dev.yml, ci/smoke-test-dev-container.sh,
.github/workflows/release-dev-image.yml, devtools/generate-dev-license-notices,
docs/dev-image.md. The three shared-code changes are each feature-gated or
inert by default:

  1. Dev-mode bind opt-in (cmd_serve.rs). Dev-mode previously refused any
    non-loopback bind. In a container that guard checks the wrong boundary:
    binding 0.0.0.0 inside a network namespace is not exposure, publishing
    the port is. The guard stays as the default; EXTENDDB_DEV_ALLOW_ANY_BIND=1
    opts out with a logged warning, gated on cfg!(feature = "dev-mode") so the
    code path does not exist in production binaries. Docs and compose examples
    uniformly publish to 127.0.0.1.
  2. Healthcheck plaintext probe (cmd_healthcheck.rs). The client stripped
    only https:// and always performed a TLS handshake, so it reported a
    healthy dev-mode server as unhealthy. Dev-mode-gated plain-HTTP probe,
    sharing request construction and response parsing with the TLS path.
  3. [profile.release-slim] (Cargo.toml). strip + fat LTO +
    codegen-units=1. Nothing selects it by default; the production release
    profile and every existing artifact are bit-identical. Binary: 36 → 20 MB.

Licence notices are per-feature-set

devtools/generate-software-license-notices generates for --features postgres, so SOFTWARE-LICENSE-NOTICES.html omits dependencies this image
actually bundles (libsqlite3-sys). A sibling generator produces
SOFTWARE-LICENSE-NOTICES-DEV.html for sqlite,dev-mode, the image ships that
file, the release workflow gates on --check, and the smoke test asserts the
shipped notices cover the sqlite dependency set. The production generator and
its release gate are untouched (relevant to #278, which adds the PR-time check
for the production file).

Release workflow: single dispatch

release-dev-image.yml mirrors release-image.yml's gate rules (strict semver
tag, ancestry in main, tag/Cargo.toml agreement) and its
tested-artifact-is-published-artifact property (native runner per arch,
docker save/load, credential-free build jobs). The deliberate difference:
one dispatch goes gate → build+smoke → publish, with the reviewer-gated
dockerhub environment approval on the publish job as the single human step.
The two-person build-then-promote split guards the production image; this is
explicitly a dev image, decided 2026-08-15. Version tags refuse overwrite and
latest only moves forward (highest-semver comparison, as in #262).

Signing uses the shared sign-image composite action from #279 (OIDC to the
signing role, Rekor on), in the same job, under the same approval. This PR
depends on #279 merging first.

Verification

  • Smoke test run against the built image: identity labels agree with
    --version; nonroot uid 65532 and no shell in the runtime image; SigV4
    CreateTable/PutItem/GetItem round-trip; file mode proves persistence
    across a full container restart
    (the discriminating assertion — a server
    that recreates its schema on boot fails it); memory mode proves the
    negative control
    (the table is gone after restart); the shipped notices
    cover libsqlite3-sys.
  • Bind opt-in proven in both directions: without the variable a 0.0.0.0
    bind is refused with the existing message; with it the server starts and
    warns.
  • Healthcheck defect was found live: the container sat unhealthy while the
    data plane answered; fixed and the healthcheck now reaches healthy in
    both modes.
  • distroless/cc rather than distroless/base because the binary links
    libgcc_s.so.1; on base the container exits 127. Found by running it.
  • cargo fmt --all -- --check exit 0; cargo clippy --all-targets -D warnings clean on both sqlite,dev-mode and postgres feature sets;
    cargo test --workspace --no-default-features --features sqlite,dev-mode
    831 passed, 0 failed, 0 filtered out.

One-time setup this PR cannot do

  • Docker Hub repository extenddb/extenddb-dev must be created.
  • The runtime base gcr.io/distroless/cc-debian12 is a new registry for this
    repo; the Dockerfile comment convention says base images change only through
    reviewed CRs, so calling it out explicitly for review.

Not in scope (follow-ups)

Checklist

  • I have read CONTRIBUTING.md
  • All tests pass (cargo test --workspace)
  • Code is formatted (cargo fmt --check)
  • Clippy is clean (cargo clippy -- -W clippy::pedantic)
  • I have added or updated tests for new functionality — the smoke test with the persistence discriminator and negative control
  • I have updated documentation if behavior changed — docs/dev-image.md
  • Breaking changes are noted below (if any)

ADR / RFC: n/a — no change to wire protocol, Storage trait, auth model, on-disk format, or public CLI surface. The bind opt-in and healthcheck probe are dev-mode-feature-gated.

Breaking changes

None. Production binaries and the Postgres image are bit-identical.


By submitting this pull request, I confirm that my contribution is made under the terms of the Apache License 2.0 and I agree to the Developer Certificate of Origin (DCO). See CONTRIBUTING.md for details.

Comment thread.github/workflows/release-dev-image.yml Dismissed
Comment thread.github/workflows/release-dev-image.yml Dismissed
Dev-mode previously refused any non-loopback bind unconditionally. Inside a
container that is the wrong lever: binding 0.0.0.0 in a network namespace is
not exposure, publishing the port is. The guard is kept as the default and an
explicit opt-in is added, gated on the dev-mode feature so the code path does
not exist in production binaries. A warning is logged when the opt-in is used.
Verified live in both directions: without the variable a 0.0.0.0 bind is
refused with the existing message; with it the server starts and warns.
The healthcheck client stripped only the https:// scheme and always performed
a TLS handshake, so it reported a healthy dev-mode (plain HTTP) server as
unhealthy. A dev-mode-gated plaintext probe is added, sharing the request
construction and response parsing with the TLS path rather than duplicating
them. Non-dev builds are unchanged.
Found by running the container healthcheck against a live dev server, which
sat at 'unhealthy' while the data plane answered correctly.
inherits release and adds strip, fat LTO, and codegen-units=1. Nothing selects
it by default: the standard release profile and every existing artifact,
including the Postgres container image, remain bit-identical. The dev
container image opts in with --profile release-slim.
Measured on the sqlite,dev-mode binary: 36 MB -> 20 MB.
Zero-config developer image: sqlite,dev-mode features on the release-slim
profile, gcr.io/distroless/cc-debian12:nonroot runtime, no init step, no
bootstrap sidecar. File-backed storage by default on /var/lib/extenddb;
in-memory mode via EXTENDDB__STORAGE__SQLITE__PATH=:memory:. Healthcheck uses
the binary's own healthcheck subcommand over plain HTTP.
distroless/cc rather than distroless/base because the binary links
libgcc_s.so.1 (unwinding); base lacks it and the container exits 127.
Measured: 46.1 MB uncompressed vs 117 MB for the Postgres image.
The existing generator produces notices for --features postgres, so the
production SOFTWARE-LICENSE-NOTICES.html omits dependencies the dev image
actually bundles (libsqlite3-sys). A sibling generator produces
SOFTWARE-LICENSE-NOTICES-DEV.html for sqlite,dev-mode and the dev image ships
that file. The production generator, its output, and its release gate are
untouched.
The compose file defines both storage modes (file-backed default; in-memory
under the 'memory' profile), host ports bound to 127.0.0.1 only.
The smoke test proves what a health ping cannot: identity labels agree with
--version; nonroot uid and no shell; a SigV4 CreateTable/PutItem/GetItem
round-trip signed with the seeded dev credential; FILE MODE PERSISTS across a
full container restart (the discriminating assertion); MEMORY MODE DOES NOT
(the negative control for the mode switch); and the shipped notices cover the
sqlite dependency set. EXTENDDB_IMAGE selects prebuilt mode for CI.
Mirrors release-image.yml's gate rules and its tested-artifact-is-published-
artifact property (native runner per arch, docker save/load, credential-free
build jobs). Deliberate difference: one dispatch runs gate, build+smoke,
publish, with the reviewer-gated dockerhub environment approval on the publish
job as the single human step -- the two-person build-then-promote split guards
the production image; this is explicitly a dev image (decided 2026-08-15).
The version tag refuses overwrite and latest only moves forward by
highest-semver comparison. The licence gate checks the DEV notices file.
Signing runs in the same job via the shared sign-image composite action (OIDC
to the signing role, Rekor on), so this workflow requires that action from
PR #279 to be on main first.
Quick start, dev-mode semantics (plain HTTP, seeded example credential, why
containment lives at the publish flag), both storage modes, healthcheck,
image properties, limitations.
CodeQL (actions/cache-poisoning/poisonable-step) flags writing the default
branch's actions cache from a job that checks out a dispatch-resolved SHA.
The gate proves that SHA is an ancestor of origin/main, so the checkout is
reviewed history, but the cache write is still the one genuine poisoning
vector the rule describes, and it buys little on a rare release build that
the LTO link dominates. Removed.
The remaining alert on the gate job has no cache usage at all and mirrors
the two open alerts on release-image.yml (#16, #17): the rule matches any
checkout-and-execute of a resolved SHA under workflow_dispatch.
LeeroyHannigan added a commit that referenced this pull request Aug 19, 2026
Covers `extenddb/extenddb-dev` as well as the Postgres image, and turns the
workflow into a per-image dispatch rather than one hardcoded repository.
The dev image needs a different page, not a trimmed one. Its whole value is
that it is zero-config, so the Overview leads with the one-line `docker run`
and the SDK call against it, then the two storage modes. It also has to carry
warnings the production page does not: plain HTTP, open authorization,
loopback-only publishing, and no real data. Those are stated up front rather
than in a limitations footnote, because someone skimming a registry page and
copying the run command is the exact person who needs them.
Both use the documented example credential verbatim, since the server seeds it
and prints it, and secret scanners recognise it as an example.
One image per dispatch rather than a matrix over both: a Docker Hub repository
that does not exist yet should fail loudly for that image alone, not fail a run
that also had valid work to do for the other. Hence the explicit
repository-exists gate, which returns a clear "create the repository first"
error instead of an opaque PATCH failure.
Ordering, worth stating because one link depends on it: the dev Overview links
`docs/dev-image.md`, which currently 404s on main because it lands with #281.
That is safe rather than a broken link in production, because the dev Overview
cannot be published until `extenddb/extenddb-dev` exists on Docker Hub, and
that repository is created as part of shipping #281. The repository-exists gate
enforces the ordering rather than relying on anyone remembering it.
Verification:
- YAML parses; the choice input resolves to both images.
- Size caps asserted in the job and checked here: Postgres Overview 4945
characters and Dev 4742, against the 25000 cap; short descriptions 74 and 89
against the 100 cap.
- Every link in the dev Overview was fetched. All 200 except
`docs/dev-image.md`, explained above.
- Dev facts were read from `Dockerfile.dev` and `docs/dev-image.md` on the #281
branch rather than restated: uid 65532, distroless cc-debian12 nonroot with
no shell, port 18443 over plain HTTP, state at /var/lib/extenddb,
`EXTENDDB__STORAGE__SQLITE__PATH=:memory:` for the ephemeral mode, and a
built-in healthcheck.
LeeroyHannigan added a commit that referenced this pull request Aug 19, 2026
…alth
The first drafts read like runbooks: tag-policy tables, a runtime-contract
table, verification instructions. That is reference material, and it belongs in
the docs these pages link to, not on a registry front page. Reworked both to
the shape a reader of a Docker Hub page actually gets value from: what it is in
one sentence, why you would use it, then one command that works.
Structure now mirrors the convention such pages follow — definition, benefits,
getting started on Docker, where to learn more, closing note. Benefits are
stated as benefits rather than as properties: works with your existing
DynamoDB API calls, no internet connection needed, no provisioned throughput,
storage or data transfer costs.
Postgres went from 641 to 456 words, dev from 609 to 347. Both are shorter than
what they replace and say more of what a reader came for. The tables are gone;
tag policy and signature verification survive as two short paragraphs because
pinning and provenance are decisions a reader makes on this page.
Two things stay that the pages we are modelling do not need. The trademark
notice, which is now a closing Note rather than a banner above the fold: it
reads as a standard legal footer there instead of as the first thing the page
says about itself, while remaining complete and unmissable. And on the dev
page, the plain-HTTP and open-authorization warning, kept in the Note with the
loopback instruction, because someone copying a run command off a registry page
is precisely who needs it. It is no longer three warnings before the reader has
learned what the image is.
Also drops a link the first draft would have shipped broken: the Postgres page
pointed at `hub.docker.com/r/extenddb/extenddb-dev`, which does not exist yet.
The Postgres Overview is publishable today, so that would have been a dead link
on a live page.
Which is why the workflow now fails on any link in the Overview that does not
return 200, loopback examples excluded. A dead link on a public front door is
worse than a blank page, and this class of error should not depend on someone
re-checking by hand. It has the intended effect immediately: the Postgres page
passes, and the dev page fails on `docs/dev-image.md`, which lands with #281.
That is correct rather than inconvenient, since the dev Overview cannot be
published until `extenddb/extenddb-dev` exists, and that repository is created
as part of shipping #281.
Verification:
- YAML parses; seven steps in order.
- The link check was run exactly as the job runs it, per file: Postgres all
200, dev failing only on the #281 doc.
- Sizes re-checked against Docker Hub's caps: 3462 and 2675 characters against
25000; short descriptions unchanged at 74 and 89 against 100.

@robinnscrobinnsc left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the workflow, all three shared-code changes, and the containment claims. Approving — the two asks below are real but concern future events (the next version bump; mid-run failure recovery), not the correctness of what ships today, and both have follow-ups already in motion.

What holds up well: the cache-poisoning avoidance (with the CodeQL rule cited), the checksummed AWS CLI, and the smoke test's discriminating assertions — file-mode persistence across restart plus the :memory: negative control is exactly the pair that catches a schema-recreating server. The shared-code changes are genuinely contained: the bind opt-in checks the right boundary (published port, not bind address) and production builds can't read the lever; the healthcheck refactor shares request/parse code between transports cleanly; finding the distroless/cc requirement by running it is the kind of verification I trust. First consumer of the #279 composite action, unmodified — good sign for the extraction.

Should-fix 1 (fine as an immediate follow-up if you'd rather not grow this PR):SOFTWARE-LICENSE-NOTICES-DEV.html recreates the #271/#272 incident class for a second file. licenses.yml's path filter doesn't watch the DEV notices or the new generator, so the next version bump goes green on PR and fails at release-dev-image's gate — "main is unreleasable" again, for the file this PR introduces. The fix is one step + two filter paths in licenses.yml. If you leave it out here, I'll send it the moment this merges — it just must not wait for the bump that proves it.

Should-fix 2: the run isn't re-entrant across the publish/sign boundary. If the job dies after imagetools create but before signing completes, re-dispatch hard-fails at the version-exists gate and the image sits published-but-unsigned. Suggest the promote-image idempotence pattern: when the version tag exists with exactly the digest this run would produce, proceed (skip pushes, sign) instead of erroring. #290 (break-glass signer with an image allowlist) covers the same gap from the recovery side; both is better than either.

Notes, non-blocking: the version tag's digest is the artifact identity (imagetools may re-create the index, so the sha-<commit> tag's digest can differ — worth stating in docs/evidence conventions); the post-publish platform verification runs while logged in, and an anonymous re-inspect would match the property the postgres flow proves; the runbook's §4 candidate-first rule now carries the recorded exception for this image's single-dispatch shape (updated today, decision 2026-08-15 cited).

Setup acknowledged: extenddb/extenddb-dev needs creating on Docker Hub before dispatch, and the distroless/cc base is a reviewed base-image addition per the Dockerfile convention.

Nice ship. The healthcheck fix alone was worth the PR.

@robinnsc
robinnsc self-requested a review August 19, 2026 18:53

@robinnscrobinnsc left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Revising my earlier approval to request changes — on reflection (and a fair challenge from this side), should-fix 1 belongs in this PR, not in a follow-up.

The reasoning I'd unwound: the gap isn't a hypothetical future concern — the very next version bump triggers it, this repo bumps versions roughly weekly, and the failure mode is the one we just lived through in #271/#272 (merge green, main unreleasable, discovered at dispatch). A PR that introduces a gated artifact should introduce its gate: SOFTWARE-LICENSE-NOTICES-DEV.html ships in this PR, so the PR-time check that keeps it fresh should too. "Follow-up right after merge" is exactly the kind of promise that becomes a standing gap, and the cost of inclusion is small — one step plus two path-filter entries in licenses.yml:

# in both path filters:
- 'SOFTWARE-LICENSE-NOTICES-DEV.html'
- 'devtools/generate-dev-license-notices'# alongside the existing check step:
- name: DEV notices must be currentrun: ./devtools/generate-dev-license-notices --check

(The existing job already installs the pinned cargo-about, so the step is free.)

Everything else from my earlier review stands as written: should-fix 2 (re-entrancy) remains non-blocking with #290 covering the recovery side, the notes remain notes, and the quality assessment is unchanged — this is a well-built PR and the licenses addition is the last piece. Will re-approve as soon as it's in.

docker buildx imagetools create -t "${REPO}:${VERSION}" "${REPO}:${CANDIDATE}"

# `latest` only moves forward: skip if any released version is higher.
HIGHEST=$( { skopeo list-tags "docker://${REPO}" 2>/dev/null \

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If skopeo list-tags fails for any reason (rate limit, network blip), HIGHEST comes back empty and the guard treats that as "no newer version exists", so latest gets pushed anyway. Releasing v0.1.5 after v0.2.0 exists would then downgrade latest.

The guard should treat "listing failed" as an error, not as "nothing found". Also: the step assumes skopeo and jq exist but the prerequisites check doesn't verify them.

So, instead if failing open, we should fail closed

Comment threadDockerfile.dev Outdated
Comment on lines +78 to +79
HEALTHCHECK --interval=10s --timeout=5s --start-period=15s --retries=5 \
CMD ["extenddb", "healthcheck", "--endpoint", "http://127.0.0.1:18443"]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The healthcheck pins http://127.0.0.1:18443 while the server honors EXTENDDB__SERVER__PORT (env overrides are how this image is configured, per the ENV block above).

container serves correctly but sits unhealthy forever, which also breaks depends_on: condition: service_healthy that docs/dev-image.md advertises

…, env-aware healthcheck
Three review findings, each verified before fixing and proven after.
1. PR-time gate for the DEV licence notices (robinnsc, blocking).
licenses.yml gains the DEV generator step plus both path-filter entries,
so SOFTWARE-LICENSE-NOTICES-DEV.html cannot recreate the #271/#272
incident class (merge green, main unreleasable, discovered at dispatch).
The notices file itself is regenerated in this commit: it still listed
ring 0.17.14 from before the crypto consolidation dropped ring from the
graph, and `generate-dev-license-notices --check` exited 1 against it.
After regeneration the check exits 0.
2. The forward-only `latest` guard now fails closed (yesyayen).
Previously `skopeo list-tags` ran inside `2>/dev/null … || true`, so a
rate limit or network blip produced an empty HIGHEST and `latest` moved
unconditionally — releasing a back-line version after a newer one would
silently downgrade `latest`. A failed listing now errors out with the
version tag already published and re-dispatch as the recovery, and a new
prerequisite gate verifies skopeo and jq before any irreversible push
(same pattern as promote-image.yml). An empty result from a successful
listing remains legitimate: that is the first release. The grep stages
are individually protected so an empty tag list cannot fail the pipeline
under pipefail.
3. The container healthcheck honours EXTENDDB__SERVER__PORT (yesyayen).
The HEALTHCHECK pinned http://127.0.0.1:18443 while the server honours
EXTENDDB__SERVER__* overrides, so a server moved with
EXTENDDB__SERVER__PORT served correctly but sat unhealthy forever, which
also wedges `depends_on: condition: service_healthy`. The Dockerfile no
longer passes --endpoint; instead `extenddb healthcheck`'s no-config
fallback resolves host and port from the same EXTENDDB__SERVER__* env the
server applies (bind_addr mapped through the existing wildcard→loopback
logic). An unparseable port is reported rather than defaulted, since
`serve` would refuse the same value and a "healthy" default-port probe
would mask the real failure. The config-file path already honoured env
via config::load and is unchanged; transport remains decided by the
build, not the URL.
Verification:
Healthcheck binary, live against a zero-config dev server:
env-moved port 28599 + env set exit 0
env-moved port + no env (old pin) exit 1, connection refused @18443
EXTENDDB__SERVER__PORT=banana exit 1, named parse error
default port, no env exit 0
Container, rebuilt image:
default port Docker health: healthy
EXTENDDB__SERVER__PORT=28123 Docker health: healthy (the
reported scenario, previously
unhealthy forever)
full ci/smoke-test-dev-container.sh PASS (8/8 stages)
Gates: fmt clean; clippy --all-targets -D warnings clean on sqlite,dev-mode
and postgres; 1015 lib tests, 0 failed, 0 filtered on both;
generate-dev-license-notices --check exit 0.
@LeeroyHannigan

Copy link
Copy Markdown
CollaboratorAuthor

All three findings addressed in 2fecc4e. Each was verified against the code before fixing and proven after — details below.

@robinnsc — DEV notices gate (your changes-requested item). In as you specified: the DEV generator step plus both path-filter entries in licenses.yml. Your reasoning held up in a way you'll appreciate: the gap wasn't just the next bump, it was already live. The committed SOFTWARE-LICENSE-NOTICES-DEV.html still listed ring 0.17.14 from before the crypto consolidation dropped ring from the graph, and generate-dev-license-notices --check exited 1 against it. So a dispatch today would have failed at the release gate — the exact discovered-at-dispatch failure the check exists to prevent. Regenerated in the same commit (+2/-24, the mirror of #287); the check now exits 0.

@yesyayen — fail-open latest guard. Fixed as you suggested: fail closed. A failed skopeo list-tags now errors out (the version tag is already published at that point, so re-dispatch is the recovery) instead of treating the registry as empty and moving latest. An empty result from a successful listing remains legitimate — that's the first release. Also added the prerequisite gate verifying skopeo and jq before any irreversible push, same pattern as promote-image.yml, and protected the grep stages individually so an empty tag list can't fail the pipeline under pipefail.

@yesyayen — healthcheck port pin. Fixed at the root rather than in the Dockerfile: the HEALTHCHECK no longer passes --endpoint, and extenddb healthcheck's no-config fallback now resolves host and port from the same EXTENDDB__SERVER__* env the server applies (bind addr mapped through the existing wildcard→loopback logic). An unparseable port errors loudly rather than defaulting, since serve would refuse the same value and a "healthy" default-port probe would mask the real failure. The config-file path already honoured env via config::load and is unchanged.

Verification on the rebuilt image: default port → healthy; EXTENDDB__SERVER__PORT=28123healthy (your scenario, previously unhealthy forever); binary-level negative controls (no env against a moved server fails with connection refused at 18443, bad port value fails with a named parse error); full smoke test 8/8. Gates: fmt, clippy -D warnings on both feature sets, 1015 lib tests 0 filtered on both, DEV notices --check clean.

…__SERVER__PORT
Discriminates the env-aware healthcheck from a hardcoded probe: the container
runs with EXTENDDB__SERVER__PORT moved off the default, and the stage requires
Docker's own HEALTHCHECK to reach healthy plus a data-plane call on the moved
port. Against the previous pinned --endpoint image this stage never reaches
healthy (verified: 45s of 'starting' with 'Cannot connect to 127.0.0.1:18443'
in the health log, while the server serves on the moved port), so the run
fails. Against the fixed image it passes.
Without this, a hardcoded port passes the existing stages whenever the default
happens to match the published port, and the upcoming default-port change would
have masked the finding entirely. host_port() now takes the container port as
an optional argument (default 18443), which the port change can reuse.
Full smoke run against the fixed image: 9/9 stages PASS.
The project default 18443 follows the 1-prefix-on-8443 scheme, which signals
TLS. This image serves plain HTTP, so an HTTPS-looking port invites https://
and a connection failure that presents as a bug. 18080 applies the same prefix
rule to the plain-HTTP convention, making the pair self-documenting: 18443 =
TLS (production images), 18080 = plain (this image). 8000 is deliberately
avoided because a comparable local emulator binds it, and running both for
differential testing would fight over the port.
Set via EXTENDDB__SERVER__PORT in the image ENV, which the server and the
now env-aware healthcheck both honour, so the change is contained to the
image and its consumers: compose (18080:18080, memory profile 18081:18080),
the smoke test's three publishes and host_port() default, and the docs'
five examples. The binary's own default stays 18443.
Verified: full smoke test 9/9 on the rebuilt image, including the moved-port
healthcheck stage; server log confirms 'listening on 0.0.0.0:18080'
zero-config; compose file validates.
@LeeroyHannigan

Copy link
Copy Markdown
CollaboratorAuthor

One more change folded in per discussion: the image's default port is now 18080 (b900ec6).

Rationale: 18443 follows the 1-prefix-on-8443 scheme, which signals TLS, and this image serves plain HTTP, so an HTTPS-looking port invites https:// and a connection failure that presents as a bug. 18080 applies the same prefix rule to the plain-HTTP convention, making the pair self-documenting: 18443 = TLS (production), 18080 = plain (this image). 8000 was deliberately avoided so differential testing against other local emulators doesn't fight over the port.

Mechanically it's EXTENDDB__SERVER__PORT=18080 in the image ENV, which the server and the now env-aware healthcheck both honour, plus compose (18080:18080, memory profile 18081:18080), the smoke test publishes, and the docs examples. The binary's own default stays 18443, so nothing outside the image changes.

Verified: full smoke suite 9/9 on the rebuilt image, including the moved-port healthcheck stage, and the zero-config server log confirms listening on 0.0.0.0:18080.

@LeeroyHannigan
LeeroyHannigan added this pull request to the merge queueAug 19, 2026
Merged via the queue into main with commit 33612ecAug 19, 2026
19 checks passed
LeeroyHannigan added a commit that referenced this pull request Aug 20, 2026
The published extenddb/extenddb-dev image exposes 18080/tcp and sets
EXTENDDB__SERVER__PORT=18080 (verified on the pulled 0.1.7 digest). This
Overview was written before that flip landed in #281, so both quickstart
commands pointed at 18443 and neither worked as printed.
Both corrected commands were run verbatim against the published image:
the container reaches Docker healthy and list-tables returns. The
memory-mode instruction was also verified, with a durable control: the
volume-backed container keeps its table across a full container recreate
while the :memory: container loses it on restart.
The Postgres Overview is unchanged; 18443 is correct there, that image
does serve TLS on it.
robinnsc added a commit that referenced this pull request Aug 20, 2026
…urrency, required reason
The image allowlist is enforced by a local case statement rather than
trusting undocumented API-side choice enforcement; the concurrency
group is per-image so a key rotation can sign images concurrently; a
required reason input is echoed to the summary AND signed into the
payload annotations (org.extenddb.break-glass-reason) so the key
attests why the break-glass path ran. extenddb-dev leaves the list
pending the shared-key decision raised in review — the production
key signing a plain-HTTP dev build is a trust decision for the
release owner, to be made with #281 before the dev image ships.
Header documents the post-pipeline role; pin comment converged to
v4.4.0.
@robinnsc

Copy link
Copy Markdown
Collaborator

One input for the docs in this PR, following the key decision recorded on #290: the production key signs the dev image too (one key, all tiers — release owner decision today, keyless signing is the long-term replacement). Since key-based cosign verify alone therefore can't distinguish a dev build from a production release, docs/dev-image.md's verify section (and any future admission-policy guidance) should tell verifiers to check the signed annotations / image repo, e.g. -a org.extenddb.release=vX.Y.Z plus pulling from the expected repository — matching what the release notes for the postgres image will say. No code change needed in this PR; the signing step's key ARN is already correct under the decision.

rlunar pushed a commit to rlunar/extenddb that referenced this pull request Aug 20, 2026
The dev image (ExtendDB#281) publishes and signs in one dispatch; if that run
dies between publish and sign, recovery needs a standalone signer,
and this one was hardcoded to extenddb-postgres. The image is now a
closed choice-list input (no free text reaches the repo reference).
Also corrects the checkout pin's version annotation.
rlunar pushed a commit to rlunar/extenddb that referenced this pull request Aug 20, 2026
…urrency, required reason
The image allowlist is enforced by a local case statement rather than
trusting undocumented API-side choice enforcement; the concurrency
group is per-image so a key rotation can sign images concurrently; a
required reason input is echoed to the summary AND signed into the
payload annotations (org.extenddb.break-glass-reason) so the key
attests why the break-glass path ran. extenddb-dev leaves the list
pending the shared-key decision raised in review — the production
key signing a plain-HTTP dev build is a trust decision for the
release owner, to be made with ExtendDB#281 before the dev image ships.
Header documents the post-pipeline role; pin comment converged to
v4.4.0.
rlunar pushed a commit to rlunar/extenddb that referenced this pull request Aug 20, 2026
…rrent (ExtendDB#289)
* docs(docker): publish a Docker Hub Overview, and a step to keep it current
The Docker Hub repository has 446 pulls and a blank Overview, and a blank
short description with it. Neither field is set by anything: `docker push`
and the `skopeo copy` that promote-image uses both write images, never
repository metadata, and nothing in CI touches the Hub API.
GHCR looks documented for the same image because it derives its package
description from the OCI labels the Dockerfile already sets
(`org.opencontainers.image.title` / `.description`). Docker Hub ignores
those labels for the Overview entirely, which is why one registry reads as
maintained and the other reads as abandoned.
`docker/README.md` was not reusable as-is. It is contributor-facing: how to
generate notices, build with `--build-arg`, run the four-role Compose stack,
run the smoke test. Someone pulling the image wants the tag policy, the
runtime contract, the fact that PostgreSQL is not bundled, and how to verify
the signature. So this adds a consumer-facing file rather than pointing the
Hub at the existing one.
Content is sourced from `docker/dockerhub-overview.md` so the published
Overview is reviewed like any other content and cannot drift from the
repository. It carries the trademark notice from the README verbatim, since
the Hub page is a public front door and previously carried no notice at all,
and links to extenddb.org.
The workflow is dispatch-only and gated on the `dockerhub` environment,
because it uses the same credential that can push images. Deliberately not
wired into promote-image: the Overview is version-neutral, so a wording fix
should not require a release and a release should not silently rewrite it.
It also keeps this clear of ExtendDB#283, which is already changing that file.
It verifies rather than assumes: after the PATCH it re-reads the repository
anonymously and diffs the published Overview against the file, so a silent
no-op or a partial write fails the run instead of looking green.
Verification:
- YAML parses; the `jq` payload construction was exercised locally without
credentials (4945 characters, against Docker Hub's 25000 cap; short
description 73 characters against its 100 cap, both asserted in the job).
- Every link in the Overview was fetched: extenddb.org, the repository, the
releases page, and the four linked docs all return 200.
- Runtime facts were taken from the Dockerfile rather than restated from
memory: port 18443, state at /var/lib/extenddb, UID/GID 10001:10001,
tini entrypoint, and a healthcheck that is liveness only.
The Hub API path itself cannot be exercised from a PR, since the credential
exists only inside the gated job. The read-back assertion is there so the
first dispatch proves it end to end rather than reporting success blindly.
* docs(docker): add the dev image Overview, publish either by dispatch
Covers `extenddb/extenddb-dev` as well as the Postgres image, and turns the
workflow into a per-image dispatch rather than one hardcoded repository.
The dev image needs a different page, not a trimmed one. Its whole value is
that it is zero-config, so the Overview leads with the one-line `docker run`
and the SDK call against it, then the two storage modes. It also has to carry
warnings the production page does not: plain HTTP, open authorization,
loopback-only publishing, and no real data. Those are stated up front rather
than in a limitations footnote, because someone skimming a registry page and
copying the run command is the exact person who needs them.
Both use the documented example credential verbatim, since the server seeds it
and prints it, and secret scanners recognise it as an example.
One image per dispatch rather than a matrix over both: a Docker Hub repository
that does not exist yet should fail loudly for that image alone, not fail a run
that also had valid work to do for the other. Hence the explicit
repository-exists gate, which returns a clear "create the repository first"
error instead of an opaque PATCH failure.
Ordering, worth stating because one link depends on it: the dev Overview links
`docs/dev-image.md`, which currently 404s on main because it lands with ExtendDB#281.
That is safe rather than a broken link in production, because the dev Overview
cannot be published until `extenddb/extenddb-dev` exists on Docker Hub, and
that repository is created as part of shipping ExtendDB#281. The repository-exists gate
enforces the ordering rather than relying on anyone remembering it.
Verification:
- YAML parses; the choice input resolves to both images.
- Size caps asserted in the job and checked here: Postgres Overview 4945
characters and Dev 4742, against the 25000 cap; short descriptions 74 and 89
against the 100 cap.
- Every link in the dev Overview was fetched. All 200 except
`docs/dev-image.md`, explained above.
- Dev facts were read from `Dockerfile.dev` and `docs/dev-image.md` on the ExtendDB#281
branch rather than restated: uid 65532, distroless cc-debian12 nonroot with
no shell, port 18443 over plain HTTP, state at /var/lib/extenddb,
`EXTENDDB__STORAGE__SQLITE__PATH=:memory:` for the ephemeral mode, and a
built-in healthcheck.
* docs(docker): rewrite the Overviews as product pages, gate on link health
The first drafts read like runbooks: tag-policy tables, a runtime-contract
table, verification instructions. That is reference material, and it belongs in
the docs these pages link to, not on a registry front page. Reworked both to
the shape a reader of a Docker Hub page actually gets value from: what it is in
one sentence, why you would use it, then one command that works.
Structure now mirrors the convention such pages follow — definition, benefits,
getting started on Docker, where to learn more, closing note. Benefits are
stated as benefits rather than as properties: works with your existing
DynamoDB API calls, no internet connection needed, no provisioned throughput,
storage or data transfer costs.
Postgres went from 641 to 456 words, dev from 609 to 347. Both are shorter than
what they replace and say more of what a reader came for. The tables are gone;
tag policy and signature verification survive as two short paragraphs because
pinning and provenance are decisions a reader makes on this page.
Two things stay that the pages we are modelling do not need. The trademark
notice, which is now a closing Note rather than a banner above the fold: it
reads as a standard legal footer there instead of as the first thing the page
says about itself, while remaining complete and unmissable. And on the dev
page, the plain-HTTP and open-authorization warning, kept in the Note with the
loopback instruction, because someone copying a run command off a registry page
is precisely who needs it. It is no longer three warnings before the reader has
learned what the image is.
Also drops a link the first draft would have shipped broken: the Postgres page
pointed at `hub.docker.com/r/extenddb/extenddb-dev`, which does not exist yet.
The Postgres Overview is publishable today, so that would have been a dead link
on a live page.
Which is why the workflow now fails on any link in the Overview that does not
return 200, loopback examples excluded. A dead link on a public front door is
worse than a blank page, and this class of error should not depend on someone
re-checking by hand. It has the intended effect immediately: the Postgres page
passes, and the dev page fails on `docs/dev-image.md`, which lands with ExtendDB#281.
That is correct rather than inconvenient, since the dev Overview cannot be
published until `extenddb/extenddb-dev` exists, and that repository is created
as part of shipping ExtendDB#281.
Verification:
- YAML parses; seven steps in order.
- The link check was run exactly as the job runs it, per file: Postgres all
200, dev failing only on the ExtendDB#281 doc.
- Sizes re-checked against Docker Hub's caps: 3462 and 2675 characters against
25000; short descriptions unchanged at 74 and 89 against 100.
* docs(docker): correct the dev Overview quickstart to port 18080
The published extenddb/extenddb-dev image exposes 18080/tcp and sets
EXTENDDB__SERVER__PORT=18080 (verified on the pulled 0.1.7 digest). This
Overview was written before that flip landed in ExtendDB#281, so both quickstart
commands pointed at 18443 and neither worked as printed.
Both corrected commands were run verbatim against the published image:
the container reaches Docker healthy and list-tables returns. The
memory-mode instruction was also verified, with a durable control: the
volume-backed container keeps its table across a full container recreate
while the :memory: container loses it on restart.
The Postgres Overview is unchanged; 18443 is correct there, that image
does serve TLS on it.
* docs(docker): point verification at the in-repo key, list all three registries
Two claims in the postgres Overview had drifted from reality.
The verification paragraph said each GitHub release attaches the public key
and gives the exact cosign verify command. That was true of v0.1.6 and is
false of v0.1.7, which carries no assets and no verify instructions. The
durable home for the key is now the repository itself: ExtendDB#283 commits
extenddb-signing.pub.pem at the root (merged 2026-08-20, link verified 200).
Pointing the Overview there survives any release whose notes are thin, and
per-release command specifics stay in release notes where tlog differences
between pre- and post-Rekor releases belong. Restoring the v0.1.7 release
assets is tracked separately.
The registry list said images are mirrored to GHCR. ECR Public already
serves 0.1.5, 0.1.6, latest and both signature artifacts (verified live),
and ExtendDB#283 makes it a first-class promotion target, so the Overview now names
both mirrors and notes the signatures travel too.
The dev Overview needed nothing: its rewrite carries no per-release
verification claim.
All seven links in the postgres Overview return 200, including the new
in-repo key path; 3,571 characters against Docker Hub's 25,000 cap.
* ci(docs): fix the false-failing verify comparison; validate before the gate
Review round on ExtendDB#289 (robinnsc), all four items.
The blocker, reproduced before fixing: the verify step compared
$(jq -r ...) output - trailing newline stripped by command substitution -
against raw `cat` output, which keeps the newline every .md file ends
with. diff therefore reported a difference on every successful publish:
the gate could never pass, and the first dispatch would have reported
failure after publishing correctly. Both sides of the comparison are now
command substitutions, so both are stripped and the comparison is
apples-to-apples. Verified against the real overview file: the old
comparison fails, the new one passes.
The should-fix: all four pre-publish checks (file exists, size caps,
link health, repository exists) are credential-free but lived inside the
environment-gated job, so a dead link burned a reviewer approval to
discover - the same shape flagged on sign-image's dev option. They now
run in an ungated `validate` job with no secrets; `publish` needs it and
carries the gate, so the reviewer approves content that has already
passed validation.
Nits: the link checker and the repo-exists probe retry transient
failures (--retry 2), which post-split cost nothing.
The PR body's stale "extenddb/extenddb-dev does not exist yet (404)"
claim is corrected in the PR description alongside this commit; the
repo was created with the 0.1.7 release.
Sign up for freeto 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.

4 participants

@LeeroyHannigan@robinnsc@yesyayen@github-advanced-security