Skip to content

Add one app-config blob for each host - #1352

Open
jwrosewell wants to merge 29 commits into
feature/10-page-middlewarefrom
feature/11-config-by-host
Open

jwrosewell wants to merge 29 commits into
feature/10-page-middlewarefrom
feature/11-config-by-host

Conversation

@jwrosewell

@jwrosewell jwrosewell commented Oct 14, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Changes

Area Change
crates/trusted-server-adapter-fastly/src 4 files, 1133 lines added, 100 removed
scripts/config-by-host-local-test.sh 1 file, 393 lines added, 0 removed
crates/trusted-server-cli/src 4 files, 373 lines added, 5 removed
crates/trusted-server-cli/tests 2 files, 327 lines added, 2 removed
docs/guide 3 files, 100 lines added, 8 removed
.github/workflows 1 file, 6 lines added, 0 removed
Cargo.lock 1 file, 2 lines added, 0 removed
crates/trusted-server-cli/Cargo.toml 1 file, 2 lines added, 0 removed
CHANGELOG.md 1 file, 1 line added, 0 removed
crates/trusted-server-core/src 1 file, 1 line added, 3 removed
scripts/README.md 1 file, 1 line added, 0 removed

Closes

Closes #1270

Spec

Spec: config-by-host, fastly-per-host-selector

Test plan

CI on this pull request at 08c3339 passed all 32 checks.

  • cargo test-fastly && cargo test-axum
  • cargo clippy-fastly && cargo clippy-axum
  • cargo fmt --all -- --check
  • JS tests: cd crates/trusted-server-js/lib && npx vitest run
  • JS format: cd crates/trusted-server-js/lib && npm run format
  • Docs format: cd docs && npm run format
  • WASM build: cargo build --package trusted-server-adapter-fastly --release --target wasm32-wasip1
  • Manual testing via fastly compute serve
  • Other: the full workflow set of this repository, run on this pull request at 08c3339

Checklist

  • Changes follow AGENTS.md conventions
  • No unwrap() in production code (clippy denies unwrap_used in CI)
  • Uses log macros as AGENTS.md asks (not println!)
  • New code has tests
  • No secrets or credentials committed

Stacks on page changes as middleware (#1351).

One Fastly service can serve several publishers, each from an app-config blob of its own. The selector, the entry TS__CONFIG__KEY in the config store linked as trusted_server_config, names a blob for each host when it contains {host}, and each request reads the blob stored under the host it names.

What this pull request does

ts config push --adapter fastly --local --key www.publisher-a.example --app-config publisher-a.toml
ts config push --adapter fastly --local --key www.publisher-b.example --app-config publisher-b.toml

fastly config-store-entry update --upsert \
  --store-id <id of the store linked as trusted_server_config> \
  --key TS__CONFIG__KEY \
  --value '{host}'

ts config push writes a key other than trusted_server_config only with --local, into the local store in fastly.toml. The entries it writes are copied into a live store with fastly config-store-entry update, a long blob's chunks before its pointer.

The entry point puts the request's host in the placeholder's place before anything reads settings, so the debug probes, the application build and the finalizers all read the blob of the publisher the request is for. Every request reads the selector from the config store before anything else, the counters and JA4 probes included, and with {host} set it also asks the store whether the host's blob exists. Only /health is answered first. Each key is pushed in lower case, because the host is lowercased and --key stores the key as typed.

A request for Reads the blob at Is answered
www.publisher-a.example www.publisher-a.example From publisher A's blob
WWW.Publisher-A.example:443 or www.publisher-a.example. www.publisher-a.example From publisher A's blob
www.publisher-b.example www.publisher-b.example From publisher B's blob
A host with no blob None 421 Misdirected Request, not to be stored
A Host that is not a host name, an IPv6 literal, not UTF-8, or no Host None 421 Misdirected Request
A host whose key would be over 255 characters None 421 Misdirected Request
Any host, under a malformed selector None 500
/health, for any host None 200, before any config is read
Any host, where the store cannot say whether it has a blob None 500

A selector without the placeholder is the key as it stands and nothing is looked up, and without the entry every request reads trusted_server_config, as on Tech Lab main. A selector is malformed when it is empty, carries whitespace, a control character or {host} more than once, carries a brace outside its {host} (such as {Host}), or is longer than 255 characters.

What keeps one publisher's settings from answering for another

The risk What prevents it
A host nobody configured is answered from a shared blob There is no fallback. A host with no blob is answered 421
A Host header is written to name another key in the store, such as the logical store ID, the selector or a chunk of a blob Only letters, digits, hyphens and dots make a host name, and nothing else is looked up. Those keys each carry an underscore
A sandbox that serves several requests hands the first publisher's application to the second The sandbox keeps each application under the key of the blob it was built from, and finds one by that key alone
A page stored for one publisher is served for another The template cache key already carries the request's host and scheme
A staged version reads production blobs A staged version reads the selector and the blobs of the store its version links as trusted_server_config, so a staging store of its own keeps staging's blobs apart, with a selector of its own such as {host}_staging
The store cannot say whether a host has a blob The request is answered 500 and not served

The reusable sandbox

Sandbox was lifecycle::Sandbox<RetainedApp>, one application for the life of the sandbox. It is lifecycle::Sandbox<RetainedApps>, which keeps each application under its blob's key.

  • EdgeZero's Sandbox<T> holds one value, builds it only when empty and hands it out by shared reference, with no way to replace it. So the holder is made once and its list sits behind a RefCell. One callback runs to completion before the next begins and no borrow is held across a build.
  • It retains only a successful build and counts every attempt, as the framework's slot did. The counters probe, the response headers and the retirement line read the holder's count.
  • It keeps the eight applications served most recently. Every application shares the sandbox's one memory bound, so the count is bounded. Eight is a judgment and not a measurement.
  • A host whose blob is removed is answered 421 once the store no longer holds it, even while a sandbox still keeps its application, because the store is asked on every request.

What the publishers on one service share

  • One secret store. Each publisher's blob names keys of its own in it.
  • The request signing keys, which belong to the service.
  • The sandbox's memory bound, where a sandbox serves several requests.

What breaks

Nothing for a service that reads one blob. A service that wrote its selector as the service-scoped __KEY entry of edgezero_runtime_env writes the same value into TS__CONFIG__KEY before it deploys a build that reads that entry, as the guide's upgrade notes say, and removes any blob at trusted_server_config from a store that holds a blob for each host.

Why

A Fastly service on Tech Lab main reads one app-config blob, at the logical store ID. An operator who runs Trusted Server for several publishers therefore runs a service for each, with its own domains, stores, deploys and rollbacks, although the code is the same and only the settings differ.

Two things stood between that and one service reading a blob for each host.

  1. The key is settled for the service, before a request is looked at.
  2. A sandbox that serves several requests keeps the one application it built first. With a blob for each host, the second publisher's request would be answered by the first publisher's application.

The selector is the entry TS__CONFIG__KEY beside the sandbox bounds, which Tech Lab main already reads from the same store, because EdgeZero v0.0.9 removed the runtime store that held a service's __KEY entry and refuses a deploy whose key names anything other than the logical store ID. It is read with try_get, so a store that cannot be read is answered 500 and never panics, and a malformed selector is answered 500 and never read as one key for every host.

Closes #1270.

jwrosewell and others added 9 commits October 9, 2026 08:48
A sandbox that serves several requests kept the one application it built
first and handed it to every request after. That is right while a service
reads one app-config blob. The commit after this one lets a service read a
blob for each host, which needs an application for each, and an
application built from one publisher's blob must never answer a request
for another's.

The sandbox's payload is now `RetainedApps`, which keeps each application
under the key of the blob it was built from and finds one by that key and
by nothing else. It retains only a successful build and counts every
attempt, as the framework's one slot did. The framework hands its payload
out by shared reference and offers no way to replace it, so the holder is
made once and its list sits behind a `RefCell`. It keeps the eight
applications served most recently, because all of them share the
sandbox's one memory bound.

The build count a sandbox reports, in the counters probe, the response
headers and the retirement line, is the holder's. A test now holds the
count a response carries to the builds made, which nothing did before.

Nothing changes for a service that reads one blob. It builds one
application under one key.
One Fastly service can serve several publishers, each from an application
config of its own. A `__KEY` selector that contains `{host}` is a
template. The entry point puts the request's host in the placeholder's
place, lowercased and without a port or a trailing dot, before anything
reads settings, so the debug probes, the application build and the
finalizers all read the blob of the publisher the request is for.

A host with no blob is answered `421 Misdirected Request`, and the answer
is not to be stored. It is never answered from the logical store ID or
from another host's blob, because a shared fallback is how a host nobody
configured comes to be served another publisher's site. Only letters,
digits, hyphens and dots make a host name, so a `Host` header cannot be
made into the logical store ID or the key of a blob's chunk, each of
which carries an underscore. A lookup that fails is answered `500`,
because a request is not served while it is unknown which blob is its
own.

The placeholder goes in the selector that already chooses the key, and
not in a switch of its own, so that a staged version stays apart from
production. A staged deploy copies a service's runtime entries into a
store of its own and points only the key selector at
`<logical-store-id>_staging`. A separate switch would be copied with the
rest, and a staged version would then read the production blobs. The
selector is replaced, so a staged version reads the one staging blob.

A selector without the placeholder is the key as it stands and nothing is
looked up, so a service that reads one blob is unchanged.
`scripts/config-by-host-local-test.sh` pushes an app-config blob under
each of two hosts and one at the logical store ID, each with a stub
origin of its own, and runs the adapter under Viceroy. Without the
placeholder every host reads the one blob. With it each host is answered
from its own origin, in any letter case and with a port or a trailing
dot, and a host with no blob, the logical store ID sent as a host and a
longer name that ends in a publisher's host are each answered 421 with
no publisher's page.

Each response carries the sandbox's counters. In the default mode the
fourth response is the first request of its sandbox, which has built one
application. In `reuse` mode, built with the reusable sandbox, it is the
fourth request of its sandbox, which has built two. Two publishers then
shared a sandbox and never an application, which is the fault a unit
test cannot show because it sits in the entry point.

CI runs both modes in the Fastly job.
The configuration guide's section on the Fastly runtime config store
gains "One blob for each host", with the push for each host, the selector
entry, a table of what each request reads and is answered, and what the
publishers on one service share. The Fastly guide points to it, and the
changelog records the addition.
The host selector refuses a label that starts or ends with a hyphen, with a test. The guide says each key is pushed in lower case and that an IPv6 literal host is refused. The thing a host selects is called an app-config blob throughout, including the refusal log line the harness greps. Comments that restated a test, the guide or another comment are cut.
…hat are not UTF-8

The selection and the module request read Host with get_header_str, which panics on a value that is not UTF-8. Both now read it with get_header and take it as text only when its bytes are valid UTF-8, so such a Host counts as no host. A selector that carries {host} then answers 421 with no lookup, and a module is shown no host. A selector without {host} no longer reads Host at all.
…asking the store

A Fastly config store key holds at most 255 characters, counted as EdgeZero counts them. A host may be 253 characters, so a selector such as {host}_v2 could build a key the store cannot hold, and the lookup then failed with KeyTooLong and answered 500. Such a key is now never looked up and counts as no blob, so the request is answered 421.
…linked as trusted_server_config

EdgeZero v0.0.9 removed the edgezero_runtime_env store that held the per-service __KEY selector, so a service that keeps a blob for each host would read the one blob at trusted_server_config for every host. The entry point now reads the selector from the TS__CONFIG__KEY entry of the app-config store on every request, beside the blobs it names and the sandbox bounds, with try_get so a failed lookup cannot panic. Without the entry the key is the logical store ID, as on main.

A selector that is empty, carries whitespace, a control character or {host} more than once, carries a brace outside its one {host}, or is longer than 255 characters is malformed and answered 500, never read as one key for every host. A store that cannot be opened or read is answered 500. The comments of main that said no selector reaches the Fastly runtime now say where it is read.
…-local

EdgeZero v0.0.9 refuses any Fastly config key other than the logical store ID, for a local push as much as a remote one, so a blob for each host could no longer be pushed. ts config push --adapter fastly with another --key now goes to a writer of its own. It validates the blob as ts config validate does, wraps it in EdgeZero's envelope, splits a blob over 8,000 bytes into EdgeZero's chunk format with the chunks before the pointer, checks every key against 255 characters before writing anything, and writes the local server's store in the fastly.toml the manifest names. --dry-run lists the keys and writes nothing.

Without --local the push is refused with a message that says so, because writing a live store needs the Fastly CLI runner that arrives with the signing key commands. --staging with --key is still refused when the arguments are parsed. A push of the logical store ID still goes through EdgeZero.
…ocal harness

The guide's section on one blob for each host writes the selector into TS__CONFIG__KEY in the store linked as trusted_server_config, pushes each host's blob with --local, lists the 421 and 500 answers and what makes a selector malformed, and points a service that kept its selector in edgezero_runtime_env at the upgrade checklist. The CLI guide and main's runtime config section say a selector entry may name another key.

The harness writes the selector into the local trusted_server_config store and starts Viceroy for each value, covering no entry, {host}, {host}_v2 beside a blob at the logical store ID, a malformed {Host}, and an old service-scoped selector in an edgezero_runtime_env store, which is not read. In reuse mode the sandbox bounds sit unscoped beside the blobs, and the harness uses main's trusted_server_secrets store.
The design now tests entry_request directly, with entry_request_reads_a_host_that_is_not_utf8_as_no_host in place of a test through the device module, and relies on the existing config_push_parses_staging_and_rejects_explicit_key for --staging with --key, so the extra parse test is removed. Core changes only in the comment on default_config_key, so the comment on default_config_store_name is main's again.
@jwrosewell
jwrosewell removed this pull request from stack #1408 October 18, 2026 20:15
@jwrosewell
jwrosewell added this pull request to stack #1418 October 19, 2026 12:18
@jwrosewell
jwrosewell removed this pull request from stack #1418 October 19, 2026 16:30
@jwrosewell
jwrosewell added this pull request to stack #1421 October 19, 2026 17:38
@jwrosewell
jwrosewell removed this pull request from stack #1421 October 19, 2026 18:29
@jwrosewell
jwrosewell added this pull request to stack #1422 October 19, 2026 19:20
@jwrosewell
jwrosewell marked this pull request as ready for review October 19, 2026 20:23

This branch has not been deployed

No deployments
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.

One service serves one publisher, because the settings are read at one key before a request is looked at

2 participants