kiwi

README.md · view source · history

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

PageWhat 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
/catalogPages 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=workerWorkers as cards, tagged by how they last deployed. Details opens the Worker in the Cloudflare dashboard
/findingsEvery finding in the snapshot, most severe first. Filter with ?code=
/docsThis README and the onboarding runbook, rendered from the repo
/apiThe 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.

StatusColourMeans
donegreenProduction is served by a Direct Upload deploy: the shared GitHub Actions workflow, or a local wrangler run, which Cloudflare records the same way
sign-offyellowOnboarded, but promotion to production waits on someone else, such as a content owner's sign-off
needs infoyellowWaiting on an answer that is in neither the repo nor the API
blockedredNeeds engineering before it can be onboarded, or has not been onboarded yet
out of scopegreyWill not be onboarded. Listed last

Worker tags

TagColourMeans
wranglergreenLast deployed with wrangler, from a laptop or from CI. Cloudflare records both the same way
dashboardyellowLast deployed from the Cloudflare dashboard, so it may not match any commit
terraformpurpleLast deployed by Terraform
unknowngreyCloudflare 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.

CodeSeverityFires when
cannot_deploycriticalA Pages project's git binding is stale or cleared, and its repo has no deploy workflow
missing_deploy_secretcriticalThe deploy workflow is present, but the repo has no CLOUDFLARE_PAGES_DEPLOY secret
ci_failingcriticalThe repo's latest Deploy run did not succeed
stale_git_bindingwarnCloudflare's git binding points at an org other than abbvie-migrated (in practice the frozen AA-PDCOE), so pushes do not deploy
no_deploy_workflowwarnThe repo has no .github/workflows/deploy.yml on its default branch or on dev
stale_productionwarnProduction was last deployed more than 90 days ago. Measured from the deployment production serves, so a recent preview does not hide it
binding_wipedinfoThe git binding is cleared and a deploy workflow is present
prod_not_onboardedinfoThe deploy workflow is present, but production is still served by a deploy that did not come from it
dual_deploy_pathinfoThe deploy workflow is present and the old git binding is still enabled. Inert while the account's GitHub App token is invalid
template_version_driftinfoThe workflow pins an older tag of cloudflare-deploy-templates than its newest release
missing_cfp_topicinfoThe repo lacks the cfp topic
dashboard_deployinfoA 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.

PartWhereWhat
Entry pointsrc/index.tsRoutes requests; scheduled() runs collection
Collectionsrc/collect/collect.tsCloudflare and GitHub into one snapshot
Finding rulessrc/collect/rules.tsPure functions over the collected rows, unit tested
APIsrc/api/router.tsRead-only JSON, plus POST /api/collect
Viewssrc/views/Server-rendered HTML for every page, and the Markdown renderer for the docs
Static filesassets/Served by Workers static assets before the Worker runs
Stylesassets/styles/tokens.css (the rhea Blocks Design System V2 palette, the status palettes and the scales) and kiwi.css (the components)
Fonts and iconsassets/fonts/, src/views/icons.tsInstrument Sans and JetBrains Mono, self-hosted (OFL); Lucide icons, inlined (ISC)
StorageD1, schema in migrations/Append-only snapshots
Scriptsscripts/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

  1. List the Pages projects and Workers on AA-BTS, and each Worker's latest deployment (author and method).
  2. List the org's repos with their topics and default branches, and the newest release tag of cloudflare-deploy-templates.
  3. 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.
  4. Run the finding rules.
  5. 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/diff compares 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 rules block in wrangler.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

EnvWorkerHostnamesAccessDeployed fromD1Collects
prodkiwi-prodkiwi.pdcoe.dev, kiwi-prod.aa-bts.workers.devPublicmain, by Actions on each mergekiwi-inventoryMondays 11:00 UTC, and on demand
devkiwi-devkiwi-dev.aa-bts.workers.devBehind Cloudflare Accessdevelop, by Actions on each mergekiwi-inventory-devOn 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.jsonc is inherited by every environment, so env.dev overrides 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

text
feature branch --PR--> develop --merge--> kiwi-dev     validate here
                          |
                          +------PR------> main --merge--> kiwi-prod
  1. Branch from develop, the default branch, and open the PR against it. CI runs the tests and the typecheck.
  2. Merging deploys develop to kiwi-dev (.github/workflows/deploy.yml): the tests again, then D1 migrations on kiwi-inventory-dev, then wrangler deploy --env dev. Check the change on kiwi-dev.
  3. Open a PR from develop into main and merge it with a merge commit. That deploys kiwi-prod the same way, migrating kiwi-inventory first.

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:

EnvSecretPermissions
devCLOUDFLARE_API_TOKENAA-BTS only: Workers Scripts: Edit, Account Settings: Read, D1: Edit
prodZONE_CLOUDFLARE_API_TOKENThe 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.

EnvWorkerHostnameStatus
prodkiwi-prodkiwi.pdcoe.devRouted: kiwi.pdcoe.dev/* in wrangler.jsonc
devkiwi-devkiwi-dev.pdcoe.devNot set up: no subzone yet, and the route is commented out

A hostname needs all of these before it serves:

  1. A partial subzone for the hostname on AA-BTS. Cloudflare gives it a verification key.
  2. In AbbVie DNS, a TXT record cloudflare-verify.<host> holding that key, which activates the subzone.
  3. In AbbVie DNS, a CNAME from <host> to <host>.cdn.cloudflare.net. Without it the hostname does not resolve.
  4. 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.
  5. 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():

HowWrites toWhen
The cronkiwi-inventoryEvery Monday at 11:00 UTC, on kiwi-prod only
POST /api/collectThat Worker's own databaseOn demand, from a script, with the Worker's COLLECT_TRIGGER_SECRET
npm run collect:remotekiwi-inventory, or kiwi-inventory-dev with D1_DATABASE_IDOn 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.

sh
curl -X POST -H "Authorization: Bearer $TRIGGER_SECRET" https://kiwi.pdcoe.dev/api/collect

collect:remote writes through the D1 REST API, so it needs no deployed Worker and no browser session:

sh
# 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:remote

It 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):

SecretWhat
COLLECT_TOKENCloudflare API token for collection. Read-only: Pages: Read and Workers Scripts: Read. Never the deploy token
GH_TOKENGitHub 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_SECRETBearer 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:

VariableUsed byDefault
CF_TOKENBothcollect.sh: wrangler's OAuth token. collect:remote: required (or COLLECT_TOKEN), and needs D1: Edit as well as the collection scopes
GH_TOKENBothcollect.sh: gh auth token. collect:remote: required
ORGBothabbvie-migrated
CF_ACCOUNTBothThe AA-BTS account id
D1_DATABASE_IDcollect:remotekiwi-inventory (prod)
OUTcollect.shdata/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.

EndpointReturns
GET /api/healthok and the environment name
GET /api/snapshotsThe 50 most recent snapshots
GET /api/summaryThe snapshot row and finding counts by code
GET /api/projectsEvery project; filter with ?platform=pages or ?platform=worker, and ?finding=<code>
GET /api/findingsEvery finding
GET /api/diffFindings resolved and introduced between two complete snapshots, the latest two by default; pick them with ?from=<id>&to=<id>
POST /api/collectRuns 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

sh
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 typecheck

The 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:

sh
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.json

This 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.ts looks 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 show unknown: 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.
  • wrangler covers laptop and CI alike. Cloudflare records an Actions deploy as wrangler, 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

  1. Give kiwi-dev a pdcoe.dev hostname: create the kiwi-dev.pdcoe.dev subzone, add its Access application, then follow the steps under Hostnames.
  2. Remove the 60-Worker cap on deploy-method lookups.
  3. Try Worker attribution with a scoped API token, which would give Workers a repo and the deploy-path checks Pages already have.
  4. Apply cfp topics to the Pages repos, so they are discoverable from the GitHub side and missing_cfp_topic clears.

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.

  • cloudflare-deploy-templates: the shared Pages and Worker deploy workflows, including the one that deploys KIWI
  • eds-site-config: EDS config-as-code; deploys the EDS routing Workers
  • rhea-docs: platform docs, including the GitHub org migration runbook
  • docs/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