Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
a49d428
fix(exposure): stop assembling a public URL from an undeclared host p…
convee Sep 5, 2026
49e50f4
fix(quickstart): detect busy host ports regardless of who owns them
convee Sep 5, 2026
a29ac82
fix(bundles): make the shipped examples deployable and surface their …
convee Sep 5, 2026
4c47e19
test(contracts): gate every claim this round fixed, and drop a stale …
convee Sep 5, 2026
bf5ba51
fix(builds): create the docker config the build Job mounts, and follo…
convee Sep 5, 2026
8a0adab
fix(cli): state the flag defaults a caller cannot otherwise see
convee Sep 5, 2026
7ad1f5e
fix(cli): say "digest", not "summary", and name the rotate command ea…
convee Sep 5, 2026
dd4b651
docs(agent-contract): specify the deployment authorization every MCP …
convee Sep 5, 2026
2d656d1
fix(contract): state that verification evidence belongs to the revisi…
convee Sep 5, 2026
4003779
fix(operator): name the quota that refused the pod instead of reporti…
convee Sep 5, 2026
19d9eac
fix(contract): bound the evidence to the path it was actually collect…
convee Sep 6, 2026
1344118
fix(standalone): take the cluster's Pod CIDR and values file like clu…
convee Sep 6, 2026
8bca9d0
docs(configuration): finish the SITES_* set instead of restating a st…
convee Sep 6, 2026
00bbe43
docs(standalone): say that the Chart's cluster-scoped CRDs make one r…
convee Sep 6, 2026
60ff880
docs(readme): put the two missing modules on the map and count the mi…
convee Sep 6, 2026
53a8627
fix(gateway): make the host-facing URL parts Chart values instead of …
convee Sep 6, 2026
eda9ecf
fix(gateway): tell the control plane which Gateway the Chart actually…
convee Sep 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 37 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,12 @@ environments, not a public hosting platform — [Known limitations](#known-limit
The supported source-checkout trial runs on macOS or Linux with hardware virtualization,
outbound HTTPS, and enough capacity for three Lima VMs configured with **8 CPUs, 10 GiB
RAM, and 70 GiB of sparse disk in total**. Install Git, Lima, Docker, `kubectl`, Helm, `curl`, `uv`,
`lsof`, and Python 3.12+. Docker must be running, not merely installed.
and Python 3.12+. Docker must be running, not merely installed.

On macOS with Homebrew, the command-line dependencies are:

```bash
brew install git lima kubectl helm uv python lsof
brew install git lima kubectl helm uv python
brew install --cask docker
open -a Docker # wait until Docker reports that it is running
```
Expand Down Expand Up @@ -175,7 +175,7 @@ git clone https://github.com/hullwork/site.git
cd site
uv sync --locked --extra dev
make test-db # starts a throwaway PostgreSQL on 127.0.0.1:55439
make test # 1031 tests
make test # 1060 tests
make test-db-down
```

Expand Down Expand Up @@ -208,6 +208,14 @@ SITES_LOCAL_PATH_PROVISIONER_ENABLED=false \
scripts/cluster.sh up
```

A public deployment on such a cluster reports **no URL** until you say how the outside
reaches it. The `nodeport` backend puts each site on one node port from 30080–30088; only
the kubeadm trial above also forwards those to host ports 18090–18098, so nothing else can
be assumed. Add `SITES_HOST_PORT_BASE` (and `SITES_PUBLIC_URL_HOST` when the forwards do
not answer on `http://127.0.0.1`) once such a forward exists. Server-side verification is
independent of this: it probes the in-cluster address, so `status.verification` is evidence
the site serves traffic whether or not a public URL is declared.

The bootstrap helper generates credentials into a mode-0700 temporary directory and never
writes them into the repository or a values file. **Do not use it for production.** For
the lower-level Helm lifecycle and production Secret contract, see
Expand All @@ -227,7 +235,16 @@ sites capabilities
`bodySha256` from a request the control plane made itself. Redirects are not followed and
only 2xx counts, because those are the two facts a tenant cannot forge. The evidence is
bounded on purpose: it proves something at that address returned 2xx with that body
digest — not that the body is semantically correct, since the tenant produced it.
digest — not that the body is semantically correct, since the tenant produced it. It is
bounded in *place* too: the probe requests the deployment's `healthPath`, and
`verification.url` names it. With the default `/` that is the address a person opens; with
a health path such as `/healthz` it is not, and a site can be verified while its public URL
answers 403. It is
also bounded in time: the evidence names the `revision` it was collected for and is kept
when a later revision fails to roll out, so `ok: true` beside `phase: Failed` is last
time's answer, not this one's. Compare `verification.revision` with the deployment's
`revision` — [docs/AGENT_CONTRACT.md](docs/AGENT_CONTRACT.md#success-criteria) states the
full acceptance rule.
- **The caller cannot name the tenancy it writes into.** Identity is a `(merchant, tenant)`
pair decided entirely by the credential. `X-Merchant-ID` and `X-User-ID` are **refused
with 403**, not ignored, so a misconfigured client fails loudly instead of quietly
Expand Down Expand Up @@ -291,8 +308,8 @@ so the reference Deployments intentionally run **one replica each**.

### Module map

`api.py` is the composition root and combines eight endpoint mixins (auth, tenants,
merchants, admin, builds, bundles, deployments, sites).
`api.py` is the composition root and combines nine endpoint mixins (auth, tenants,
merchants, admin, builds, bundles, deployments, sites, mcp) over the shared HTTP kit.

<details>
<summary>Per-module responsibilities under <code>src/sites/</code></summary>
Expand All @@ -301,7 +318,8 @@ merchants, admin, builds, bundles, deployments, sites).
|---|---|
| `api.py` | Composition root: combines mixins, assembles `serve()`, applies exception mappings, owns metric route templates |
| `api_errors.py` | Ordered exception-to-HTTP mappings shared by mutation endpoints |
| `api_auth.py`, `api_tenants.py`, `api_merchants.py`, `api_admin.py`, `api_builds.py`, `api_bundles.py`, `api_deployments.py`, `api_sites.py` | The endpoint mixins |
| `api_auth.py`, `api_tenants.py`, `api_merchants.py`, `api_admin.py`, `api_builds.py`, `api_bundles.py`, `api_deployments.py`, `api_sites.py`, `api_mcp.py` | The endpoint mixins; `api_mcp.py` is the MCP tool surface as `POST /mcp` |
| `http_kit.py` | JSON and static-file helpers, bounded body reads, route matching, console SPA fallback; no control-plane logic |
| `identity.py` | Pure authentication: `(headers, store, tokens) → Identity or Refusal` |
| `admission.py` | Pure admission, quota, and port-allocation logic plus refusal exceptions |
| `validation.py` | Input validation (`normalize_*`), identity and quota constants, `DEPLOY_FIELDS`, `STATIC_IMAGE` |
Expand Down Expand Up @@ -401,6 +419,13 @@ production is worse off than one who reads them here.
the reference Chart. Admission uses a process-local lock and reconciliation has no leader
election, so scaling past one replica requires distributed admission and leader election
first.
- **Two tenant limits disagree, and only one of them refuses at submission time.** Admission
counts deployments against `maxDeployments` (default 10) and answers `429 quota_exceeded`.
The tenant namespace also carries a `ResourceQuota` of `SITES_TENANT_CPU_LIMIT` (default 4)
against a fixed `limits.cpu: 1` per site, so a tenant can run **4** sites at once. Numbers
5 through 10 are accepted with a 200 and then fail to roll out. The refusal now names the
exhausted quota rather than only reporting a readiness timeout, but the two numbers are
still set independently.
- `sync_once()` holds the API's mutation lock across a Kubernetes `GET`. The Kubernetes
client timeout (10s) and the mutation-lock acquire timeout (`SITES_MUTATION_LOCK_TIMEOUT`,
10s) are the same order of magnitude, so a slow apiserver can make write paths return
Expand Down Expand Up @@ -428,14 +453,11 @@ production is worse off than one who reads them here.

**Housekeeping**

- **Historical UIDs remain in the Git history.** The working tree is clean, but publishing
with history attached does not clear them; a `git filter-repo` pass is required before the
repository is made public.
- 45 `SITES_*` environment variables are read by `src/sites/` but appear in no document or
chart — mostly activator, gateway, NodePort-pool, KEDA, and operator tuning. They have
working defaults, and the most useful ones are now listed under
[Undocumented tuning variables](docs/CONFIGURATION.md#undocumented-tuning-variables), but
the set is not yet complete or schema-validated.
- `SITES_*` environment variables are still not schema-validated: an invalid value
generally raises at import time rather than falling back. Every one `src/sites/` reads is
named in a document or the chart — the leftovers are under
[Undocumented tuning variables](docs/CONFIGURATION.md#undocumented-tuning-variables) — and
a test keeps that true, but "named" is not "validated".
- `api.py`, `operator.py`, and `storage.py` are large, and environment reads are spread
across process-owned modules instead of validated once at startup.

Expand Down
3 changes: 2 additions & 1 deletion charts/site/templates/07-build-plane.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -401,7 +401,8 @@ spec:
policyTypes:
- Egress
egress:
# The target pushed by buildctl is sites-registry.sites-local.svc:5000, and the service name must be resolved first.
# The target pushed by buildctl is the sites-registry Service in
# namespaces.control on port 5000, and the service name must be resolved first.
- to:
- namespaceSelector:
matchLabels:
Expand Down
36 changes: 25 additions & 11 deletions charts/site/templates/08-gateway.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,12 @@
# URL of the party. When the three places are allocated separately, the performance of any one place drifting is "the site cannot be accessed" - and three copies
# The configuration alone is reasonable. When placed in ConfigMap, there is no such state as "only two changes" physically.
#
# Change to your own wildcard domain name: change SITES_DOMAIN_SUFFIX below, **at the same time** change Gateway listener
# hostname (below this file, two places are nailed by GatewayManifestContractTest), and then give
# `*.<your suffix>` Add a DNS record pointing to the gateway. Domain name goes 80/443 when SITES_GATEWAY_SCHEME
# and SITES_GATEWAY_HOST_PORT must also be changed accordingly, otherwise the URL returned to the caller will contain local
# 18090 port.
# Change to your own wildcard domain name with `gateway.domainSuffix`: it renders both
# SITES_DOMAIN_SUFFIX below and the Gateway listener's wildcard, so the two cannot be
# changed apart (they used to be two literals kept together by
# GatewayManifestContractTest). Point `*.<your suffix>` at the gateway in DNS, and set
# `gateway.scheme`/`gateway.hostPort` to how the host really answers -- they default to
# the reference Lima forward, and left alone they hand the caller a URL naming port 18090.
apiVersion: v1
kind: ConfigMap
metadata:
Expand All @@ -41,10 +42,22 @@ data:
# Only when this value is gateway, the site will use the L7 entrance and scaleToZero will be allowed.
SITES_EXPOSURE_BACKEND: "gateway"
# Site hostname = <serviceName>-<tenant summary>.<this suffix>.
SITES_DOMAIN_SUFFIX: "127.0.0.1.sslip.io"
SITES_DOMAIN_SUFFIX: {{ .Values.gateway.domainSuffix | quote }}
# Host-facing URL scheme and port for the local Lima port forwarding.
SITES_GATEWAY_SCHEME: "http"
SITES_GATEWAY_HOST_PORT: "18090"
# Which Gateway the control plane points at. The object below is named after
# namespaces.gateway, while the code defaulted both of these to the literal
# "sites-gateway", so they agreed only when the release happened to be
# installed into a namespace of that name -- and both install scripts set
# namespaces.gateway to the release namespace, which by default is
# sites-local. Everywhere else the operator wrote HTTPRoutes whose parentRef
# named a Gateway that does not exist and a tenant NetworkPolicy that admitted
# a data-plane Pod label nothing carries. Neither shows up as an error: the
# route is created, the deployment is Running, verification passes on the
# in-cluster address, and the public URL answers 404.
SITES_GATEWAY_NAME: {{ .Values.namespaces.gateway | quote }}
SITES_GATEWAY_NAMESPACE: {{ .Values.namespaces.gateway | quote }}
SITES_GATEWAY_SCHEME: {{ .Values.gateway.scheme | quote }}
SITES_GATEWAY_HOST_PORT: {{ .Values.gateway.hostPort | quote }}
# The tenant image is in the workload registry, not the CP Registry that hosts the control plane image.
# The node containerd receives this stable name from the deployment portal to the Registry pull Service.
SITES_REGISTRY_PULL_HOST: {{ .Values.registry.pullHost | quote }}
Expand Down Expand Up @@ -134,9 +147,10 @@ spec:
# This is especially true for real domain names - `*.*.apps.example.com` is a two-level wildcard that most DNS service providers
# Not supported. The host_for of sites/exposure.py and this together form the same contract.
#
# When changing to self-built wildcard DNS, change SITES_DOMAIN_SUFFIX and the hostname here. The two must be synchronized.
# (GatewayManifestContractTest pinned).
hostname: "*.127.0.0.1.sslip.io"
# Rendered from gateway.domainSuffix, the same value SITES_DOMAIN_SUFFIX above
# takes, so self-built wildcard DNS is one value rather than two edits that
# have to be kept in step (GatewayManifestContractTest still checks the pair).
hostname: {{ printf "*.%s" .Values.gateway.domainSuffix | quote }}
allowedRoutes:
# 🔴 Must be Selector instead of Same: HTTPRoute is built in the **tenant** Namespace,
# And Gateway is in sites-gateway. Leaving it as the default Same will cause every tenant route to be
Expand Down
28 changes: 27 additions & 1 deletion charts/site/templates/10-control-plane.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,15 @@ spec:
# It will also be printed out by any env in the same Pod.
- name: SITES_REGISTRY_USERNAME
value: sites
# The registry Service is created in namespaces.control, so its
# address is not a constant. Leaving these to the code defaults
# pinned the namespace at sites-local: any other install pushed
# builds at, and resolved digests against, a Service that is not
# there, and nothing said so.
- name: SITES_REGISTRY_PUSH_HOST
value: {{ printf "sites-registry.%s.svc:5000" .Values.namespaces.control | quote }}
- name: SITES_REGISTRY_API
value: {{ printf "http://sites-registry.%s.svc:5000" .Values.namespaces.control | quote }}
- name: SITES_CONTROL_NAMESPACE
value: {{ .Values.namespaces.control | quote }}
- name: SITES_HTTP_PORT
Expand Down Expand Up @@ -592,10 +601,27 @@ spec:
# It will also be printed out by any env in the same Pod.
- name: SITES_REGISTRY_USERNAME
value: sites
# The registry Service is created in namespaces.control, so its
# address is not a constant. Leaving these to the code defaults
# pinned the namespace at sites-local: any other install pushed
# builds at, and resolved digests against, a Service that is not
# there, and nothing said so.
- name: SITES_REGISTRY_PUSH_HOST
value: {{ printf "sites-registry.%s.svc:5000" .Values.namespaces.control | quote }}
- name: SITES_REGISTRY_API
value: {{ printf "http://sites-registry.%s.svc:5000" .Values.namespaces.control | quote }}
- name: SITES_CONTROL_NAMESPACE
value: {{ .Values.namespaces.control | quote }}
- name: SITES_PUBLIC_URL_HOST
value: http://127.0.0.1
value: {{ .Values.nodePort.publicUrlHost | quote }}
# Declared only where this environment really does forward a host
# port to the NodePort pool. Left out, exposure.host_port_base()
# returns None and a public deployment reports no URL rather than one
# built from a mapping this host may not have. See values.yaml.
{{- with .Values.nodePort.hostPortBase }}
- name: SITES_HOST_PORT_BASE
value: {{ . | quote }}
{{- end }}
# The Pod network this release's NetworkPolicies exclude, and this
# Pod's own address to check it against. The operator refuses to
# start if the address is outside the declaration, or if the
Expand Down
46 changes: 46 additions & 0 deletions charts/site/values.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
"mcpEndpoint",
"monitoring",
"namespaces",
"nodePort",
"postgresql",
"service",
"storage",
Expand Down Expand Up @@ -271,7 +272,10 @@
"type": "object",
"additionalProperties": false,
"required": [
"domainSuffix",
"enabled",
"hostPort",
"scheme",
"serviceType"
],
"properties": {
Expand Down Expand Up @@ -300,6 +304,21 @@
],
"minimum": 30000,
"maximum": 32767
},
"domainSuffix": {
"type": "string",
"minLength": 1
},
"scheme": {
"enum": [
"http",
"https"
]
},
"hostPort": {
"type": "integer",
"minimum": 1,
"maximum": 65535
}
}
},
Expand Down Expand Up @@ -362,6 +381,33 @@
"pattern": "^$|^([0-9]{1,3}\\.){3}[0-9]{1,3}/[0-9]{1,2}$"
}
}
},
"nodePort": {
"type": "object",
"additionalProperties": false,
"required": [
"publicUrlHost",
"hostPortBase"
],
"properties": {
"publicUrlHost": {
"type": "string",
"minLength": 1
},
"hostPortBase": {
"oneOf": [
{
"type": "string",
"pattern": "^$|^[0-9]{1,5}$"
},
{
"type": "integer",
"minimum": 1,
"maximum": 65535
}
]
}
}
}
},
"$defs": {
Expand Down
25 changes: 25 additions & 0 deletions charts/site/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,31 @@ gateway:
serviceType: LoadBalancer
httpNodePort: null
metricsNodePort: null
# The host-facing half of a gateway URL: what sits between a site's own name
# and its path. scheme and hostPort are only what the caller is handed --
# routing does not read them -- and default to the reference trial's Lima
# forward, so on any other host they are the first two values to set.
# domainSuffix is load-bearing: it is both the listener's wildcard and the
# hostname the operator writes into every HTTPRoute. One value feeds both, so
# changing it cannot leave the two disagreeing; point `*.<suffix>` at the
# gateway in DNS before using anything but the sslip.io default.
domainSuffix: 127.0.0.1.sslip.io
scheme: http
hostPort: 18090

# How a browser outside the cluster reaches a site published by the `nodeport`
# exposure backend. Both keys describe the machine in front of the cluster, not
# the cluster, so hostPortBase has no default for the same reason
# clusterNetwork.podCIDR has none: a wrong one is not visibly wrong. The
# reference kubeadm trial owns a relay that forwards guest 30080..30088 to host
# 18090..18098 and therefore declares hostPortBase: 18090. Where no such mapping
# exists, leaving it empty makes a public deployment report no URL, instead of
# one assembled from a host port that something unrelated may be serving.
# Server-side verification is unaffected either way: it uses the in-cluster
# address and remains the evidence that the site serves traffic.
nodePort:
publicUrlHost: http://127.0.0.1
hostPortBase: ""

monitoring:
enabled: false
Expand Down
2 changes: 1 addition & 1 deletion console/src/components/TenantsView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ export default function TenantsView({
<span>{t("Tenant ID")}</span>
<input
value={draftUser}
placeholder={t("The only one in the merchant")}
placeholder={t("Unique within the merchant")}
onChange={(event) => setDraftUser(event.target.value)}
/>
</label>
Expand Down
4 changes: 2 additions & 2 deletions console/src/i18n.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ const en = {
"New tenant": "New tenant",
"Search tenants": "Search tenants",
"Search for merchant or tenant ID": "Search for merchant or tenant ID",
"The only one in the merchant": "The only one in the merchant",
"Unique within the merchant": "Unique within the merchant",
"Create and issue token": "Create and issue token",
"No matching tenant": "No matching tenant",
"There are no tenants on the platform yet": "There are no tenants on the platform yet",
Expand Down Expand Up @@ -554,7 +554,7 @@ const zhCN: Record<TranslationKey, string> = {
"New tenant": "新建租户",
"Search tenants": "搜索租户",
"Search for merchant or tenant ID": "搜索商户或租户 ID",
"The only one in the merchant": "商户内唯一",
"Unique within the merchant": "商户内唯一",
"Create and issue token": "创建并签发 token",
"No matching tenant": "没有匹配的租户",
"There are no tenants on the platform yet": "平台上还没有租户",
Expand Down
15 changes: 10 additions & 5 deletions deploy-specs/example-single.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
{
"name": "example-web",
"image": "nginx:1.29-alpine",
"port": 80,
"healthPath": "/",
"exposure": "public"
"name": "example-single",
"components": [
{
"name": "single-web",
"image": "nginxinc/nginx-unprivileged:1.29-alpine",
"port": 8080,
"healthPath": "/",
"exposure": "public"
}
]
}
Loading
Loading