Kit for Integrated Web Infrastructure: the inventory and reference for AbbVie Aesthetics' Cloudflare estate (Workers and Pages) on the AA-BTS account.
A generated catalog of what is actually deployed, joined to the repos that deploy it, with health findings surfaced on top.
It answers a question no single system can: which Cloudflare projects do we run, from which repos, and are any of them broken? Without KIWI that takes a series of Cloudflare and GitHub API calls, and the answer is out of date as soon as you stop.
The live catalog is kiwi.pdcoe.dev, also served at kiwi-prod.aa-bts.workers.dev. It is public, with no sign-in.
The site
| Page | What it shows |
|---|---|
/ | The Overview. Key numbers for the latest snapshot (Pages projects, Workers, Pages projects on Actions, repos in the org), finding counts by code, and how the Pages projects last deployed |
/catalog | Pages projects as cards, coloured by rollout status. Details shows the build shape, custom domains and the reason for the status, with links to the Cloudflare dashboard and the repo. Filter with ?status=; 12 cards a page |
/catalog?platform=worker | Workers as cards, tagged by how they last deployed. Details opens the Worker in the Cloudflare dashboard |
/findings | Every finding in the snapshot, most severe first. Filter with ?code= |
/docs | This README and the onboarding runbook, rendered from the repo |
/api | The JSON API reference, with examples that run against whichever environment you are reading |
The Overview, Catalog and Findings take ?snapshot=<id> to show a past snapshot.
Pages are rendered on the server and work without JavaScript; assets/scripts/kiwi.js only enhances them. The theme (system, light or dark) is kept in a cookie the server reads, so the chosen theme is painted on first load.
Every page that shows data names its snapshot and how old it is, and can carry one of two warnings:
- Incomplete snapshot (red): collection hit an error, so counts are a floor, not a total. The snapshot's notes say what failed.
- Stale snapshot (yellow): the snapshot is more than 8 days old, so the weekly cron has missed a run. See Refreshing the data.
Pages rollout status
Each Pages project is in one of five states. rolloutStatus() in src/collect/rules.ts derives them from the collected data. Only the reasons no API can supply are written by hand, in ROLLOUT_NOTES in the same file.
| Status | Colour | Means |
|---|---|---|
| done | green | Production is served by a Direct Upload deploy: the shared GitHub Actions workflow, or a local wrangler run, which Cloudflare records the same way |
| sign-off | yellow | Onboarded, but promotion to production waits on someone else, such as a content owner's sign-off |
| needs info | yellow | Waiting on an answer that is in neither the repo nor the API |
| blocked | red | Needs engineering before it can be onboarded, or has not been onboarded yet |
| out of scope | grey | Will not be onboarded. Listed last |
Worker tags
| Tag | Colour | Means |
|---|---|---|
| wrangler | green | Last deployed with wrangler, from a laptop or from CI. Cloudflare records both the same way |
| dashboard | yellow | Last deployed from the Cloudflare dashboard, so it may not match any commit |
| terraform | purple | Last deployed by Terraform |
| unknown | grey | Cloudflare recorded no method KIWI recognises, or the method was not looked up (see Known gaps) |
Findings
The rules are evaluate() in src/collect/rules.ts. Severities and descriptions live in the finding_code table, set by the migrations. A rule never fires on missing data: NULL means "not determined", so a failed probe cannot turn into a false finding.
| Code | Severity | Fires when |
|---|---|---|
cannot_deploy | critical | A Pages project's git binding is stale or cleared, and its repo has no deploy workflow |
missing_deploy_secret | critical | The deploy workflow is present, but the repo has no CLOUDFLARE_PAGES_DEPLOY secret |
ci_failing | critical | The repo's latest Deploy run did not succeed |
stale_git_binding | warn | Cloudflare's git binding points at an org other than abbvie-migrated (in practice the frozen AA-PDCOE), so pushes do not deploy |
no_deploy_workflow | warn | The repo has no .github/workflows/deploy.yml on its default branch or on dev |
stale_production | warn | Production was last deployed more than 90 days ago. Measured from the deployment production serves, so a recent preview does not hide it |
binding_wiped | info | The git binding is cleared and a deploy workflow is present |
prod_not_onboarded | info | The deploy workflow is present, but production is still served by a deploy that did not come from it |
dual_deploy_path | info | The deploy workflow is present and the old git binding is still enabled. Inert while the account's GitHub App token is invalid |
template_version_drift | info | The workflow pins an older tag of cloudflare-deploy-templates than its newest release |
missing_cfp_topic | info | The repo lacks the cfp topic |
dashboard_deploy | info | A Worker was last deployed from the Cloudflare dashboard |
How it works
KIWI is one Worker. It serves the site and the API, and collects the inventory on a schedule.
| Part | Where | What |
|---|---|---|
| Entry point | src/index.ts | Routes requests; scheduled() runs collection |
| Collection | src/collect/collect.ts | Cloudflare and GitHub into one snapshot |
| Finding rules | src/collect/rules.ts | Pure functions over the collected rows, unit tested |
| API | src/api/router.ts | Read-only JSON, plus POST /api/collect |
| Views | src/views/ | Server-rendered HTML for every page, and the Markdown renderer for the docs |
| Static files | assets/ | Served by Workers static assets before the Worker runs |
| Styles | assets/styles/ | tokens.css (the rhea Blocks Design System V2 palette, the status palettes and the scales) and kiwi.css (the components) |
| Fonts and icons | assets/fonts/, src/views/icons.ts | Instrument Sans and JetBrains Mono, self-hosted (OFL); Lucide icons, inlined (ISC) |
| Storage | D1, schema in migrations/ | Append-only snapshots |
| Scripts | scripts/ | collect-remote.ts and d1-rest.ts collect from a terminal into the remote D1; collect.sh collects into a local JSON file |
A collection run
- List the Pages projects and Workers on AA-BTS, and each Worker's latest deployment (author and method).
- List the org's repos with their topics and default branches, and the newest release tag of
cloudflare-deploy-templates. - For each Pages project, probe its repo: is there a deploy workflow, which template tag does it pin, is the deploy secret set (existence only, since GitHub never returns values), and how did the latest Deploy run end.
- Run the finding rules.
- Write one snapshot: the project, finding and repo rows, with the counts on the snapshot row.
A run makes about 180 API calls across the two providers and writes about 1,800 rows, most of them repos.
A provider error does not stop the run. The snapshot is still written, marked incomplete, with the error in its notes, so a failed run shows up in the data rather than reading as the estate shrinking. A probe that fails for one repo leaves only that project's columns empty.
Design choices
- History, not current state. Snapshots are append-only, so the useful questions have answers: how long has this been wrong, and did that batch of changes fix anything?
/api/diffcompares two snapshots. That is why storage is D1, not KV, which would overwrite the history. - Nothing generated is committed. The catalog is rendered from D1 on each request. The docs are this repo's Markdown, bundled into the Worker as text by the
rulesblock inwrangler.jsonc. The site and the repo cannot disagree, and there is no publish step to forget. - A catalog, not a demo. Demo code carries no traffic, so nothing fails when it drifts. Teams ask which pattern to pick and who already runs it, and the production estate answers that better than a sample would.
- Unknown stays unknown. Where data is missing, the catalog shows a dash, not a guess. A wrong owner or repo is worse than a blank.
Environments
| Env | Worker | Hostnames | Access | Deployed from | D1 | Collects |
|---|---|---|---|---|---|---|
| prod | kiwi-prod | kiwi.pdcoe.dev, kiwi-prod.aa-bts.workers.dev | Public | main, by Actions on each merge | kiwi-inventory | Mondays 11:00 UTC, and on demand |
| dev | kiwi-dev | kiwi-dev.aa-bts.workers.dev | Behind Cloudflare Access | develop, by Actions on each merge | kiwi-inventory-dev | On demand only |
Both Workers have the three secrets described under Auth.
The two never share state:
- Separate databases. A branch's migration or a test collection never lands in prod's history.
- One schedule. Only prod has the cron. The top-level cron in
wrangler.jsoncis inherited by every environment, soenv.devoverrides it with an explicit empty list.
Bindings, unlike the cron, are not inherited by named environments, so assets and d1_databases are repeated under each one. Leaving them out deploys a Worker whose database is undefined.
Cloudflare Access applies per hostname, not account-wide. A new hostname is public until it has its own Access application, so give a kiwi-dev hostname one before routing it. To check a hostname, run curl -sI <url>: a redirect to cloudflareaccess.com means it is covered.
Branches and deploys
feature branch --PR--> develop --merge--> kiwi-dev validate here
|
+------PR------> main --merge--> kiwi-prod- Branch from
develop, the default branch, and open the PR against it. CI runs the tests and the typecheck. - Merging deploys
developtokiwi-dev(.github/workflows/deploy.yml): the tests again, then D1 migrations onkiwi-inventory-dev, thenwrangler deploy --env dev. Check the change on kiwi-dev. - Open a PR from
developintomainand merge it with a merge commit. That deployskiwi-prodthe same way, migratingkiwi-inventoryfirst.
Migrations run before the deploy, so new code never meets an old schema, and a migration reaches prod only after it has run on dev.
One ruleset, Default Branch Protection, covers develop and main: no deletion or force push, changes only by PR with one approval, and merge commits only. Repo admins can bypass it. A squash or rebase would give main commits that develop lacks, so the next release PR would list every change again.
The ruleset does not require status checks, so check them by hand before merging: test on every PR, and source-branch on a PR into main, which fails unless the PR comes from develop.
The deploy job is the shared deploy-worker.yml from abbvie-migrated/cloudflare-deploy-templates, pinned to the v1.2.3 tag. It takes a single Cloudflare token, so each environment has its own repo secret holding everything its deploy needs:
| Env | Secret | Permissions |
|---|---|---|
| dev | CLOUDFLARE_API_TOKEN | AA-BTS only: Workers Scripts: Edit, Account Settings: Read, D1: Edit |
| prod | ZONE_CLOUDFLARE_API_TOKEN | The same, plus Zone: Read and Workers Routes: Edit on the kiwi.pdcoe.dev zone, for prod's route |
A routed zone missing from its environment's token fails the deploy, so add each new zone to the token before its route merges.
To redeploy a branch without a change, for example after a failed deploy, run the workflow by hand: gh workflow run Deploy -R abbvie-migrated/kiwi --ref develop (or --ref main). A Worker secret change needs no redeploy; wrangler secret put deploys a new version itself.
npm run deploy:dev and deploy:prod are break-glass, for when Actions or its token is broken. They deploy whatever is checked out on your machine, skip the migrations (run migrate:dev or migrate:remote first if one is new), and last only until the next merge deploys over them.
Hostnames
pdcoe.dev is AbbVie-managed DNS. Each hostname is its own Cloudflare subzone in partial (CNAME) setup: the nameservers stay at ns1-4.abbviedns.com, and the subzone activates through a CNAME to <host>.cdn.cloudflare.net in the parent zone. This matches the six existing *.pdcoe.dev rhea subzones.
Routes are therefore declared with zone_name equal to the subzone itself, not the pdcoe.dev apex, which is not a zone on this account.
| Env | Worker | Hostname | Status |
|---|---|---|---|
| prod | kiwi-prod | kiwi.pdcoe.dev | Routed: kiwi.pdcoe.dev/* in wrangler.jsonc |
| dev | kiwi-dev | kiwi-dev.pdcoe.dev | Not set up: no subzone yet, and the route is commented out |
A hostname needs all of these before it serves:
- A
partialsubzone for the hostname on AA-BTS. Cloudflare gives it a verification key. - In AbbVie DNS, a TXT record
cloudflare-verify.<host>holding that key, which activates the subzone. - In AbbVie DNS, a CNAME from
<host>to<host>.cdn.cloudflare.net. Without it the hostname does not resolve. - In the subzone, a proxied DNS record for the hostname, so Cloudflare answers for it. Cloudflare's name then resolves:
dig +short <host>.cdn.cloudflare.net. - The route in
wrangler.jsonc, and the zone on the deploy token (see Branches and deploys).
Uncomment a route only once its subzone is active: a route for a zone Cloudflare does not answer for fails the deploy. The edge certificate is issued after the CNAME resolves, so HTTPS can lag DNS by a few minutes.
kiwi-prod sets workers_dev: true explicitly. Wrangler turns *.workers.dev off when a Worker has routes and the setting is absent, which would take down kiwi-prod.aa-bts.workers.dev and every link to it.
Refreshing the data
Three ways, all running the same collect():
| How | Writes to | When |
|---|---|---|
| The cron | kiwi-inventory | Every Monday at 11:00 UTC, on kiwi-prod only |
POST /api/collect | That Worker's own database | On demand, from a script, with the Worker's COLLECT_TRIGGER_SECRET |
npm run collect:remote | kiwi-inventory, or kiwi-inventory-dev with D1_DATABASE_ID | On demand, from a terminal, with no Worker involved |
The cron is written 0 11 * * MON. Use the three-letter weekday: Cloudflare numbers weekdays from 1 = Sunday, so a numeric weekday fires a day early. Cron runs in UTC only, and a schedule change takes up to 15 minutes to reach the edge.
POST /api/collect waits for the run to finish and returns the new snapshot's id and counts. It returns 503 when the Worker has no COLLECT_TRIGGER_SECRET, and 401 for a wrong one. On kiwi-dev the request also has to get past Access, so use collect:remote there.
curl -X POST -H "Authorization: Bearer $TRIGGER_SECRET" https://kiwi.pdcoe.dev/api/collectcollect:remote writes through the D1 REST API, so it needs no deployed Worker and no browser session:
# Into kiwi-inventory (prod).
CF_TOKEN=... GH_TOKEN=$(gh auth token -u PARKPX_AbbVie) npm run collect:remote
# Into kiwi-inventory-dev.
D1_DATABASE_ID=879bd361-b6af-4da7-98c4-45b75ea92139 CF_TOKEN=... GH_TOKEN=$(gh auth token -u PARKPX_AbbVie) npm run collect:remoteIt sends one REST call per row and retries a call whose connection drops or that returns 429 or 5xx. It never retries the plain INSERT that creates the snapshot, since a repeat would make a second one. If a write still fails, it marks the snapshot incomplete. It prints the new snapshot's counts, and exits non-zero if the snapshot is incomplete.
When the stale warning shows, refresh with either on-demand route, then look for the missed scheduled run in kiwi-prod's Workers Logs in the Cloudflare dashboard (observability is on).
Auth
The Worker secrets, set per environment with wrangler secret put <NAME> --env prod (or --env dev):
| Secret | What |
|---|---|
COLLECT_TOKEN | Cloudflare API token for collection. Read-only: Pages: Read and Workers Scripts: Read. Never the deploy token |
GH_TOKEN | GitHub token with read access to the org's repos: metadata, contents, Actions, and secrets (names only). A fine-grained token expires, so if snapshots come back incomplete with a github error in their notes, check it first |
COLLECT_TRIGGER_SECRET | Bearer secret for POST /api/collect. Unset, the endpoint stays closed |
Collection needs both COLLECT_TOKEN and GH_TOKEN. Without them a run records an empty, incomplete snapshot.
The scripts read these variables:
| Variable | Used by | Default |
|---|---|---|
CF_TOKEN | Both | collect.sh: wrangler's OAuth token. collect:remote: required (or COLLECT_TOKEN), and needs D1: Edit as well as the collection scopes |
GH_TOKEN | Both | collect.sh: gh auth token. collect:remote: required |
ORG | Both | abbvie-migrated |
CF_ACCOUNT | Both | The AA-BTS account id |
D1_DATABASE_ID | collect:remote | kiwi-inventory (prod) |
OUT | collect.sh | data/inventory.json, which is gitignored |
Warning
The wrangler OAuth token expires about hourly. wrangler refreshes it transparently; raw curl does not. A stale token returns Authentication error, which looks the same as a permissions failure and will send you debugging the wrong thing. Run any wrangler command first to force a refresh, or use a scoped API token.
collect.sh checks both APIs first and exits non-zero on an auth failure, rather than writing an inventory full of empty sections that reads like "nothing is deployed".
API
Read-only JSON over the latest snapshot. The full reference, with examples, is at /api.
| Endpoint | Returns |
|---|---|
GET /api/health | ok and the environment name |
GET /api/snapshots | The 50 most recent snapshots |
GET /api/summary | The snapshot row and finding counts by code |
GET /api/projects | Every project; filter with ?platform=pages or ?platform=worker, and ?finding=<code> |
GET /api/findings | Every finding |
GET /api/diff | Findings resolved and introduced between two complete snapshots, the latest two by default; pick them with ?from=<id>&to=<id> |
POST /api/collect | Runs collection; see Refreshing the data |
Add ?snapshot=<id> to read a past snapshot. Until a first snapshot exists, everything but health and snapshots returns 503.
Development
npm install
npm run migrate:local # schema into the local D1
npm run dev # http://127.0.0.1:8787
npm test # vitest: finding rules, views, Markdown renderer, D1 REST adapter
npm run typecheckThe local database starts empty, so the pages say there is no snapshot yet. To fill it, put the three secrets in .dev.vars (gitignored) and send POST /api/collect to the local server.
The remote commands:
npm run migrate:dev # schema -> kiwi-inventory-dev; CI runs this before each dev deploy
npm run migrate:remote # schema -> kiwi-inventory (prod); CI runs this before each prod deploy
npm run deploy:dev # break-glass only; merging to develop deploys kiwi-dev
npm run deploy:prod # break-glass only; merging to main deploys kiwi-prod
npm run collect:local # shell collector -> data/inventory.jsonThis README is served at /docs/readme, and its first paragraph is repeated as the Overview's lede (INTRO in src/views/catalog.ts), so change the two together. src/views/markdown.ts renders a subset of Markdown: headings, pipe tables, fenced code, lists, blockquotes and GitHub admonitions, inline code, links and bold. A test renders this file and docs/onboarding.md and fails if a heading or table is lost. Relative links become site links (docs/onboarding.md becomes /docs/onboarding), so link only to docs the site serves, and name other files in code.
Known gaps
- Worker deploy methods stop at 60 Workers.
collect.tslooks up deployments for the first 60 Workers in Cloudflare's list, which is alphabetical. The comment beside the cap says the list is sorted by recency; it is not. Workers past the 60th showunknown: at snapshot 23, with 68 Workers, the last 8 by name. The fix is to remove the cap, at one extra API call per Worker beyond 60. - Workers have no source repo. Cloudflare's Workers Builds API rejected the wrangler OAuth token on every endpoint tried, so the collector does not read a Worker's repo, and Worker cards have no repo link. Inferring the repo from the GitHub side is unreliable: a
wrangler.*config proves only that someone prepared a Worker, and some repos have one with nothing deployed. A scoped API token has not been tried. - No deploy-path checks for Workers. Without a repo there is nothing to probe, so the workflow, secret and CI findings cover Pages only. Workers get only
dashboard_deploy. - No Worker routes or domains. They are not collected, so Worker cards have no site link.
wranglercovers laptop and CI alike. Cloudflare records an Actions deploy aswrangler, attributed to the token's owner, exactly like a local run.- Org visibility is partial. The collector sees what
GH_TOKEN's identity can see, so repo counts are a floor.
Roadmap
- Give kiwi-dev a
pdcoe.devhostname: create thekiwi-dev.pdcoe.devsubzone, add its Access application, then follow the steps under Hostnames. - Remove the 60-Worker cap on deploy-method lookups.
- Try Worker attribution with a scoped API token, which would give Workers a repo and the deploy-path checks Pages already have.
- Apply
cfptopics to the Pages repos, so they are discoverable from the GitHub side andmissing_cfp_topicclears.
Onboarding the estate
Pages projects are moving off Cloudflare's broken git integration onto the shared GitHub Actions workflow, in batches. docs/onboarding.md is the running tracker: the ordering, the per-batch procedure, and what is deliberately excluded. The catalog shows where each project stands.
Onboarding a repo for the first time? Start at How to onboard one repo: the prerequisites, where the deploy secret goes, how to baseline before deploying, and how to verify production actually switched over.
The runbook is also readable on the site at /docs/onboarding, so following it needs no repo access.
Related
cloudflare-deploy-templates: the shared Pages and Worker deploy workflows, including the one that deploys KIWIeds-site-config: EDS config-as-code; deploys the EDS routing Workersrhea-docs: platform docs, including the GitHub org migration runbookdocs/patterns: notes on the Cloudflare patterns in use on AA-BTS, each naming the production project that proves it. In the repo only, not on the site