@skillcdn/console 0.1.8 → 0.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/skills/building-a-console/SKILL.md +4 -4
- package/skills/building-a-console/references/the-default-console.md +1 -1
- package/skills/deploying-a-console/SKILL.md +11 -10
- package/skills/deploying-a-console/assets/deployer-policy.json +13 -12
- package/skills/deploying-a-console/assets/secrets.example.json +4 -4
- package/skills/deploying-a-console/references/access.md +10 -9
- package/skills/deploying-a-console/references/resources.md +107 -50
- package/skills/deploying-a-console/references/sizing.md +1 -1
- package/skills/deploying-a-console/scripts/store-secrets.mjs +1 -15
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skillcdn/console",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"description": "What a custom SkillCDN console is built from: the API client and schemas, the components, the composition of the default console, and the console command agents work the board with.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://github.com/skillcdn/console#readme",
|
|
@@ -5,7 +5,7 @@ license: MIT
|
|
|
5
5
|
compatibility: A shell with Node.js 24 or newer and npm or pnpm, and an agent that reads and writes files. For looking at the pages against a board, the console command signed in by a person. A browser the agent can drive is optional. No key of the agent's own is needed, and it never sees a token.
|
|
6
6
|
metadata:
|
|
7
7
|
author: skillcdn
|
|
8
|
-
version: "1.
|
|
8
|
+
version: "1.2"
|
|
9
9
|
skillcdn:
|
|
10
10
|
translations:
|
|
11
11
|
ko:
|
|
@@ -50,7 +50,7 @@ Nothing here restates them: they are the contract, current at the version instal
|
|
|
50
50
|
| Which kind | Asked when the request does not say: "Is this console for you alone, served from your machine with your token; for the whole organization, in place of the default pages in the image; or a script against the API?" A member usually wants their own. |
|
|
51
51
|
| Where the organization's build goes | Asked for the organization's: "How is the console run: with the deployment repository that deploying-a-console made, with an image you build yourselves, or from a directory mounted into it?" The answer places the result: the source in `console-ui/` of that repository, which its workflow builds on every push; a repository of its own whose build goes beside the Dockerfile of [deploy/README.md](/deploy/README.md) "A custom console in the image"; or the build in the directory. |
|
|
52
52
|
| What it must show or do | From the request: a page, a component, a language, a brand, a column, a filter, a report. Asked for its missing half: "What should it show or do that the default console does not?" Everything else is the default console's and stays. |
|
|
53
|
-
| The marks | Asked for a console that shows marks of its own, for the half the request leaves open: "Which picture should the pages show beside the board's name: a small mark, the name as lettering, or both? Where is the file? The pages are dark, so it should be a light one." `brand` takes either or both. A mark drawn in a dark colour vanishes on the dark pages: ask for a light version, or, with the person's word, recolour it when it is one colour. Default: the board's name alone and no marks, which is the honest console. |
|
|
53
|
+
| The marks | Asked for a console that shows marks of its own, for the half the request leaves open: "Which picture should the pages show beside the board's name: a small square mark, the name as lettering, or both? Where is the file? The pages are dark, so it should be a light one." `brand` takes either or both. The symbol's slot is a square of one line's height (`.sc-brand-symbol`, 1rem): a wide mark, a lettermark of three or four times its height, is a wordmark and goes in as one, alone or beside a square symbol. A mark drawn in a dark colour vanishes on the dark pages: ask for a light version, or, with the person's word, recolour it when it is one colour. Default: the board's name alone and no marks, which is the honest console. |
|
|
54
54
|
| The colour | Asked when the request names none and marks are wanted: "Which colour, or which mood?" A colour comes as words or a value; the value is the mark's own fill when it has one, else chosen and shown. A light colour becomes the accent (`--sc-color-accent` and its family); a dark one tints the field and its light (`--sc-color-field`, `--sc-color-light-*`), with a lighter shade of it as the accent, since an accent must read on the dark field. The mapping is shown at the plan. |
|
|
55
55
|
| The languages | Asked when the organization's language is not among the packs: "Which languages should the pages speak?" Default: the packs the package ships, chosen by the browser; a Korean organization gets Pretendard loaded, as the default console does. |
|
|
56
56
|
| The name | Not asked: the server names the board (`WORKSPACE_NAME`), and `title` is only what the page says until the server answers. `index.html` gets the organization's `<title>` and `lang`. |
|
|
@@ -66,7 +66,7 @@ Produces the **plan** and the first build. Install first, in the new folder, so
|
|
|
66
66
|
|
|
67
67
|
### Phase 2: Configure before replacing
|
|
68
68
|
|
|
69
|
-
Produces the **configuration**. `createConsole(config)` takes `title`; `brand`, the addresses of a symbol and a wordmark; `languages`, the packs the pages speak, where a language of one's own is `src/i18n/en.ts` copied whole and translated; `components`, the pieces to replace, with the props `ConsoleComponents` names; `baseUrl`, the API's origin, the page's own when left out; and `client`, for tests. A console with marks of its own imports their files in `main.tsx`, so that the build takes them as assets
|
|
69
|
+
Produces the **configuration**. `createConsole(config)` takes `title`; `brand`, the addresses of a symbol and a wordmark; `languages`, the packs the pages speak, where a language of one's own is `src/i18n/en.ts` copied whole and translated; `components`, the pieces to replace, with the props `ConsoleComponents` names; `baseUrl`, the API's origin, the page's own when left out; and `client`, for tests. A console with marks of its own imports their files in `main.tsx`, so that the build takes them as assets, and passes their addresses through `brand`; `build: { assetsInlineLimit: 0 }` in `vite.config.ts` keeps every picture and font a file, since the policy allows a small picture inlined as a `data:` address but no `data:` font, and the default console only happens to have none small enough; sets `index.html`'s `<title>`, its `lang` and its icons, the mark as `favicon.svg` linked directly, with an `.ico` and a touch icon when the person has them (the default console links three from its brand package through a small build plugin); and loads the faces its languages need. Most wants are configuration or the styles: re-skinning starts and mostly ends with the tokens of `console.css` (the colours, the glass, the field), overridden by a stylesheet of the console's own loaded after the package's, with the mapping of the plan. Replace a component only when configuration cannot do it: start from its source under `src/components/`, keep its props, and keep or add `sc-` classes. With the look in place, show the pages and stop for the look checkpoint.
|
|
70
70
|
|
|
71
71
|
### Phase 3: Compose pages of one's own
|
|
72
72
|
|
|
@@ -74,7 +74,7 @@ Produces the **pages**. For a page the default console lacks: `useConsoleData(cl
|
|
|
74
74
|
|
|
75
75
|
### Phase 4: Run it against the board
|
|
76
76
|
|
|
77
|
-
Produces the **running console**. `console serve` serves the API at `http://127.0.0.1:11197/api/` while `npx vite` serves the pages, and `console serve dist` serves the build whole at `http://127.0.0.1:11197/`. The pages hold no token; the command carries it, on the loopback only. The pages know they hold a token (`GET /api/v1/me` answers `agent`) and offer nothing a token cannot do: the tokens themselves and configuring stay on the organization's console. The organization's console is looked at the same way, on the builder's token: it shows every page a token may see, and the pages a token may not (the agents, the settings) are the package's own and unchanged, so the look checkpoint covers everything the console changed. Where the organization's build then goes is the plan's answer: `console-ui/` of the deployment repository, whose workflow builds it into the image on every push, handed over to deploying-a-console, the skill beside this one at the same address (in the package under `node_modules/@skillcdn/console/skills/deploying-a-console/`), with the source and the repository; the build beside the Dockerfile of [deploy/README.md](/deploy/README.md) "A custom console in the image", for an image the organization builds itself; or the mounted directory. A script: `createClient({ baseUrl, token })`, every answer parsed with the package's schemas.
|
|
77
|
+
Produces the **running console**. `console serve` serves the API at `http://127.0.0.1:11197/api/` while `npx vite` serves the pages, and `console serve dist` serves the build whole at `http://127.0.0.1:11197/`. The pages hold no token; the command carries it, on the loopback only. The pages know they hold a token (`GET /api/v1/me` answers `agent`) and offer nothing a token cannot do: the tokens themselves and configuring stay on the organization's console. The organization's console is looked at the same way, on the builder's token: it shows every page a token may see, and the pages a token may not (the agents, the settings) are the package's own and unchanged, so the look checkpoint covers everything the console changed. Where the organization's build then goes is the plan's answer: `console-ui/` of the deployment repository, whose workflow builds it into the image on every push, handed over to deploying-a-console, the skill beside this one at the same address (in the package under `node_modules/@skillcdn/console/skills/deploying-a-console/`), with the source and the repository, and with the package pinned at the version of the release the deployment pins as `CONSOLE_REF` (the tag `@skillcdn/console@<version>`), so that the pages and the server are one release and move together; the build beside the Dockerfile of [deploy/README.md](/deploy/README.md) "A custom console in the image", for an image the organization builds itself; or the mounted directory. A script: `createClient({ baseUrl, token })`, every answer parsed with the package's schemas.
|
|
78
78
|
|
|
79
79
|
### Phase 5: Check and show
|
|
80
80
|
|
|
@@ -137,4 +137,4 @@ export default defineConfig({
|
|
|
137
137
|
}
|
|
138
138
|
```
|
|
139
139
|
|
|
140
|
-
What a console of one's own changes. In the page: its `<title>` and `lang`, and its icons, linked directly in the page or through the plugin, for its own. In the script: the `title` it passes; the two `@skillcdn/brand` imports, for its own symbol and wordmark (a light one, since the pages are dark), or none; the face it loads, for its languages (`npm install pretendard` for Korean). In the build: the proxy's target, `http://127.0.0.1:11197` with `changeOrigin: true` for `console serve`; the default console's own proxy points at the `api` role's development port, which a console of one's own does not have, and its plugin reads the icons from the brand package, which a console of one's own replaces with files of its own. In the compiler's settings: `jsx`, `moduleResolution` and the `vite/client` types stay, which make an imported picture a module; `extends` and `references` are the repository's and go, with `"strict": true` of the console's own in their place.
|
|
140
|
+
What a console of one's own changes. In the page: its `<title>` and `lang`, and its icons, linked directly in the page or through the plugin, for its own. In the script: the `title` it passes; the two `@skillcdn/brand` imports, for its own symbol and wordmark (a light one, since the pages are dark), or none; the face it loads, for its languages (`npm install pretendard` for Korean). In the build: the proxy's target, `http://127.0.0.1:11197` with `changeOrigin: true` for `console serve`; the default console's own proxy points at the `api` role's development port, which a console of one's own does not have, and its plugin reads the icons from the brand package, which a console of one's own replaces with files of its own. In the compiler's settings: `jsx`, `moduleResolution` and the `vite/client` types stay, which make an imported picture a module; `extends` and `references` are the repository's and go, with `"strict": true` of the console's own in their place. One thing to add in the build: `build: { assetsInlineLimit: 0 }`, so that every picture and font stays a file; Vite inlines an asset under 4 KB as a `data:` address, which the policy allows for a picture and refuses for a font (`font-src 'self'`), and the default console gets by only because none of its assets is that small.
|
|
@@ -5,7 +5,7 @@ license: MIT
|
|
|
5
5
|
compatibility: Needs a shell with Node.js 24 or newer, git, curl, jq and the AWS CLI v2, the gh command signed in or an empty repository the person made, network access to AWS, Cloudflare, GitHub and npm, an AWS account and a domain on Cloudflare the person can create a key and a token for, and a browser on any device for the few steps only a signed-in person can take. Works in any agent that can run a shell and write files; on Windows, in Git Bash with path conversion off.
|
|
6
6
|
metadata:
|
|
7
7
|
author: skillcdn
|
|
8
|
-
version: "1.
|
|
8
|
+
version: "1.1"
|
|
9
9
|
skillcdn:
|
|
10
10
|
translations:
|
|
11
11
|
ko:
|
|
@@ -44,7 +44,7 @@ Check these before the first message. Without the AWS CLI, offer to install it (
|
|
|
44
44
|
|---|---|
|
|
45
45
|
| How many people | Asked: "How many people will use the console within a year: just you, up to 10, up to 30, or up to 100?" The answer picks the size ([sizing.md](references/sizing.md)). |
|
|
46
46
|
| The board's name | Asked, with the organization's name as the suggestion when the request gives one: "What should the board be called? Usually the organization's name." |
|
|
47
|
-
| The web address | Asked: "Which web address should the console have? A domain you already own, and where it is registered (I set it up on Cloudflare; you change its nameservers there once, and I give you the steps), or should I find one to buy (I show the price before buying)? I suggest `console.<domain>`." A domain is needed: the tunnel has no free hostname. |
|
|
47
|
+
| The web address | Asked: "Which web address should the console have? A domain you already own, and where it is registered (I set it up on Cloudflare; you change its nameservers there once, and I give you the steps), or should I find one to buy (I show the price before buying)? I suggest `console.<domain>`." A domain is needed: the tunnel has no free hostname. A domain that already serves the organization's site is fine: only the console's hostname is added to its zone, and nothing of the site is touched (resources.md "A zone that serves other hosts"). |
|
|
48
48
|
| How people sign in, and who | Asked: "How should people sign in: with GitHub, with Google (Workspace), or both? Who is in: everyone's GitHub login or Google address, and who among them configures the console (you, at least)? With a Google Workspace, everyone of the domain can be in." |
|
|
49
49
|
| The organization's skills | Asked: "Where are your organization's skills on SkillCDN, if anywhere yet? An address like `/gh/<owner>/<repo>`." Default: none; a project names one later on its Settings page. |
|
|
50
50
|
| The AWS account | Asked: "Do you have an AWS account for this, or should the console get an account of its own (the simplest to run and to read the bill of)?" A new account is made by the person, on the paid plan, with the order of cost already in front of them (the second message). |
|
|
@@ -52,8 +52,8 @@ Check these before the first message. Without the AWS CLI, offer to install it (
|
|
|
52
52
|
| The run's folder | Asked with a suggestion: "Which folder should the deployment live in on this machine? I suggest `console-deploy` under your home folder." It is made at once and becomes the deployment repository. |
|
|
53
53
|
| The deployment repository | Asked: "Where should the deployment's files live on GitHub: a private repository under your account, or under an organization you name?" Its name is the folder's. |
|
|
54
54
|
| Where alerts go | Asked: "Which email should the cost alert go to?" |
|
|
55
|
-
| The console's version | Derived: the
|
|
56
|
-
| Everything else | Derived and shown in the plan: the size's numbers, the names (`console` for the cluster and the database, `api` and `worker` for the services, `console-files-<account id>` for the bucket), the generated secrets (`AUTH_SECRET`, the database password), the variables of [deploy/README.md](/deploy/README.md) with the tunnel's settings (`TRUSTED_PROXIES=127.0.0.1`, `CLIENT_IP_HEADER=cf-connecting-ip`, `HTTP_KEEP_ALIVE_SECONDS=100`), the estimate of this size and the next, the budget alert at one and a half times the estimate, the rate limiting rule on the sign-in paths, and what the person does once. |
|
|
55
|
+
| The console's version | Derived: the latest release of the console, the tag `@skillcdn/console@<version>` of its repository: the last line of `git ls-remote --tags https://github.com/skillcdn/console 'refs/tags/@skillcdn/console@*' \| sort -t@ -k3 -V` is the highest version, and its first column the commit (an annotated tag lists its commit on a `^{}` line, which sorts last the same way), pinned in the deployment repository as `CONSOLE_REF`. An organization's own console (building-a-console) depends on the package at that same version, so that the pages and the server are one release. An update is the next tag, a new commit there. |
|
|
56
|
+
| Everything else | Derived and shown in the plan: the size's numbers, the names (`console` for the cluster and the database, `api` and `worker` for the services, `console-files-<account id>` for the bucket), the generated secrets (`AUTH_SECRET`, the database password), the variables of [deploy/README.md](/deploy/README.md) with the tunnel's settings (`TRUSTED_PROXIES=127.0.0.1,::1`, `CLIENT_IP_HEADER=cf-connecting-ip`, `HTTP_KEEP_ALIVE_SECONDS=100`) and the bucket read and written as the task's own role (`S3_CREDENTIALS=platform`), so that no key exists for it; the connector's image, mirrored into the organization's image repository; the estimate of this size and the next, the budget alert at one and a half times the estimate, the rate limiting rule on the sign-in paths, and what the person does once. |
|
|
57
57
|
|
|
58
58
|
## Workflow
|
|
59
59
|
|
|
@@ -61,7 +61,7 @@ Each phase produces a named artifact and ends with one line to the person. The p
|
|
|
61
61
|
|
|
62
62
|
### Phase 1: Access
|
|
63
63
|
|
|
64
|
-
Produces the **access record**: the account id, the region, the default VPC and its subnets, the Cloudflare account and the zone of the domain (or none yet), the git host owner. Make the run's folder with its `scratch/` subfolder and `.gitignore`; install the `console` command and copy this skill's scripts into `scratch/`; import the key into the profile `console` and read the token from its file, as access.md says, and verify each with the harmless calls there
|
|
64
|
+
Produces the **access record**: the account id, the region, the default VPC and its subnets, the Cloudflare account and the zone of the domain (or none yet; or one that serves the organization's site already), the git host owner. Make the run's folder with its `scratch/` subfolder and `.gitignore`; install the `console` command and copy this skill's scripts into `scratch/`; import the key into the profile `console` and read the token from its file, as access.md says, and verify each with the harmless calls there. What a harmless call can show, report now with the row to add: an action the deployer's policy lacks. A Cloudflare token's permissions show only at the first creation, since Read lists what Edit makes, so the step that fails names the row (resources.md "When a step fails").
|
|
65
65
|
|
|
66
66
|
### Phase 2: Plan and estimate
|
|
67
67
|
|
|
@@ -69,7 +69,7 @@ Produces the **plan**, shown at the checkpoint. From the answers: the size and w
|
|
|
69
69
|
|
|
70
70
|
### Phase 3: Foundation
|
|
71
71
|
|
|
72
|
-
Produces the **resource record**: each resource with its name and id. In this order: the zone at Cloudflare, so that nameservers propagate meanwhile; the network and its two security groups; the database, started, and waited for while the rest is made; the bucket
|
|
72
|
+
Produces the **resource record**: each resource with its name and id. In this order: the zone at Cloudflare, with the records the domain serves already found by Cloudflare's scan and checked by the person against their registrar's DNS page, so that nameservers propagate meanwhile; the network and its two security groups; the database, started, and waited for while the rest is made; the bucket; the image repository, the log group, the roles, the task's with the bucket's policy, the cluster; the tunnel, the hostname, always-HTTPS for the hostname and the rate limiting rule; the parameters, generated and derived, from the secrets file. Then, the hostname being known, the message for each secret the person holds (access.md "The OAuth app", "The OAuth client"): "Save it in the secrets file as the line I show, or say `later` and that way of signing in stays off until it is set." A value the person pastes into the chat instead is written into the file by the agent's file tool, never repeated, and the person is told to generate a new one once the run is over, since the chat kept a copy; the new one goes in as "a secret rotated" of the record.
|
|
73
73
|
|
|
74
74
|
### Phase 4: The deployment repository
|
|
75
75
|
|
|
@@ -77,7 +77,7 @@ Produces the **repository**: `deploy/console.env`, the task definitions `deploy/
|
|
|
77
77
|
|
|
78
78
|
### Phase 5: Live
|
|
79
79
|
|
|
80
|
-
Produces the **live address**. The zone is active (else the wait of "How the person is involved"); `https://<hostname>/readyz` answers `200` through Cloudflare; the page shows the sign-in buttons of the providers set (none set: the stop of "How the person is involved"); the tunnel shows a connector; the log says `api listening`; the
|
|
80
|
+
Produces the **live address**. The zone is active (else the wait of "How the person is involved"); `https://<hostname>/readyz` answers `200` through Cloudflare; the page shows the sign-in buttons of the providers set (none set: the stop of "How the person is involved"); the tunnel shows a connector; the log says `api listening` and `files go to the bucket`; the rollout is complete. Create the budget alert. Report the address.
|
|
81
81
|
|
|
82
82
|
### Phase 6: The first people
|
|
83
83
|
|
|
@@ -85,7 +85,7 @@ Produces the **first administrator and the first project**. Tell the person: "Op
|
|
|
85
85
|
|
|
86
86
|
### Phase 7: Proof
|
|
87
87
|
|
|
88
|
-
Produces the **proof**: `console task new "Deployment check"` with a body that says what this run deployed, `console take <number>` (the number
|
|
88
|
+
Produces the **proof**: `console task new "Deployment check"` with a body that says what this run deployed, `console take <number>` (`task new --json` answers the task, whose `.number` this is; `take --json` answers the run and the task, and the run's id is `.run.id`), `console hand-in scratch/deployment-check.md --label "the check"` with a small file written for it, `console run <id> --json` showing the file with its hash, the object under that hash in the bucket (`aws s3api head-object`), and `console finish --summary "<what was deployed, and what is owed>"`. The task stays on the board as the first record of the deployment.
|
|
89
89
|
|
|
90
90
|
### Phase 8: Record and delivery
|
|
91
91
|
|
|
@@ -93,7 +93,7 @@ Produces **`DEPLOY.md`** in the repository, from the template in resources.md: w
|
|
|
93
93
|
|
|
94
94
|
### Running it again
|
|
95
95
|
|
|
96
|
-
The same skill, from phase 2, with the plan naming only what changes: a new version (the commit in `deploy/console.env`, a push), the next size (the numbers in the task definitions and a push; the database's class with one command, as sizing.md "Growing" says), a member added or removed (`MEMBERS` and `ADMINS` in `deploy/api.task.json`, a push), a secret rotated (the parameter overwritten, the service rolled), the console paused or removed (resources.md "Removing"). Each is confirmed at the plan, and the record is updated with it.
|
|
96
|
+
The same skill, from phase 2, with the plan naming only what changes: a new version (the next release tag's commit as `CONSOLE_REF` in `deploy/console.env`, with `console-ui/` moved to the same version of the package when there is one, a push), a new connector (`CLOUDFLARED_VERSION` there, a push), the next size (the numbers in the task definitions and a push; the database's class with one command, as sizing.md "Growing" says), a member added or removed (`MEMBERS` and `ADMINS` in `deploy/api.task.json`, a push), a secret rotated (the parameter overwritten, the service rolled), the console paused or removed (resources.md "Removing"). Each is confirmed at the plan, and the record is updated with it.
|
|
97
97
|
|
|
98
98
|
### Verdicts
|
|
99
99
|
|
|
@@ -113,7 +113,7 @@ The same skill, from phase 2, with the plan naming only what changes: a new vers
|
|
|
113
113
|
5. Nothing of the deployment listens on the internet: the tasks' security group has no inbound rule, the database is not publicly accessible, the bucket blocks public access, and the hostname reaches the console only through the tunnel.
|
|
114
114
|
6. Names and ids go into the repository and the record; a value a secret scanner would flag never does, and `scratch/`, the key file and the token file never enter git. Every file the repository gets is shown before it is pushed.
|
|
115
115
|
7. What was created is kept and recorded, never silently deleted; removing the deployment is a run of its own, asked for, and confirmed resource by resource.
|
|
116
|
-
8. The console's contract holds on every rollout: `migrate` runs and exits `0` before the new `api` and `worker` start, the stop timeout stays above the shutdown grace period, and no secret is a build argument.
|
|
116
|
+
8. The console's contract holds on every rollout: `migrate` runs and exits `0` before the new `api` and `worker` start, as the one-off task of the workflow, or, without a pipeline, as a container of every task of both roles that its own container waits on (resources.md "Without a pipeline"); the stop timeout stays above the shutdown grace period, and no secret is a build argument.
|
|
117
117
|
|
|
118
118
|
## Terms
|
|
119
119
|
|
|
@@ -121,6 +121,7 @@ The same skill, from phase 2, with the plan naming only what changes: a new vers
|
|
|
121
121
|
|---|---|
|
|
122
122
|
| Size | `small`, `medium` or `large`: the numbers of sizing.md, chosen by how many people. |
|
|
123
123
|
| The deployer | The IAM user whose key the agent acts with, as the profile `console` of the AWS CLI. |
|
|
124
|
+
| The task's role | The role the containers run as, `console-task`, whose credentials the platform hands them and renews: it may read and write the bucket, and nothing else; no key exists for the files. |
|
|
124
125
|
| The run's folder | The folder the person chose, which becomes the deployment repository; `scratch/`, the key file and the token file in it never enter git; the secrets file is beside it, in the folder above. |
|
|
125
126
|
| The tunnel | The Cloudflare Tunnel whose connector runs beside the `api` container; the hostname's traffic comes in through it and nothing else does. |
|
|
126
127
|
| Task, service | On ECS, a task is the containers of one role running together; a service keeps the wanted number of tasks running and rolls them to a new definition. |
|
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
"Resource": "*"
|
|
70
70
|
},
|
|
71
71
|
{
|
|
72
|
-
"Sid": "
|
|
72
|
+
"Sid": "RolesOfTheConsole",
|
|
73
73
|
"Effect": "Allow",
|
|
74
74
|
"Action": [
|
|
75
75
|
"iam:CreateRole",
|
|
@@ -83,18 +83,9 @@
|
|
|
83
83
|
"iam:ListAttachedRolePolicies",
|
|
84
84
|
"iam:ListRolePolicies",
|
|
85
85
|
"iam:PassRole",
|
|
86
|
-
"iam:TagRole"
|
|
87
|
-
"iam:CreateUser",
|
|
88
|
-
"iam:DeleteUser",
|
|
89
|
-
"iam:GetUser",
|
|
90
|
-
"iam:PutUserPolicy",
|
|
91
|
-
"iam:DeleteUserPolicy",
|
|
92
|
-
"iam:CreateAccessKey",
|
|
93
|
-
"iam:DeleteAccessKey",
|
|
94
|
-
"iam:ListAccessKeys",
|
|
95
|
-
"iam:TagUser"
|
|
86
|
+
"iam:TagRole"
|
|
96
87
|
],
|
|
97
|
-
"Resource":
|
|
88
|
+
"Resource": "arn:aws:iam::*:role/console-*"
|
|
98
89
|
},
|
|
99
90
|
{
|
|
100
91
|
"Sid": "ServiceLinkedRolesAndTheGitHostProvider",
|
|
@@ -108,6 +99,16 @@
|
|
|
108
99
|
],
|
|
109
100
|
"Resource": "*"
|
|
110
101
|
},
|
|
102
|
+
{
|
|
103
|
+
"Sid": "Gauges",
|
|
104
|
+
"Effect": "Allow",
|
|
105
|
+
"Action": [
|
|
106
|
+
"cloudwatch:GetMetricData",
|
|
107
|
+
"cloudwatch:GetMetricStatistics",
|
|
108
|
+
"cloudwatch:ListMetrics"
|
|
109
|
+
],
|
|
110
|
+
"Resource": "*"
|
|
111
|
+
},
|
|
111
112
|
{
|
|
112
113
|
"Sid": "BudgetAlert",
|
|
113
114
|
"Effect": "Allow",
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
|
-
"_": "The shape of the secrets file the run keeps outside any repository and deletes, on the person's word, once every value is in the parameter store. A value is what the run generated or derived, what the person gave, or the word later; a provider the person did not choose has no line.",
|
|
2
|
+
"_": "The shape of the secrets file the run keeps outside any repository and deletes, on the person's word, once every value is in the parameter store. A value is what the run generated or derived, what the person gave, or the word later; a provider the person did not choose has no line. The bucket on AWS needs no line: the task's own role reads and writes it.",
|
|
3
3
|
"AUTH_SECRET": "generated: 48 random characters, never shown",
|
|
4
4
|
"DATABASE_URL": "derived: postgres://console:<generated password>@<database endpoint>:5432/console",
|
|
5
5
|
"DATABASE_CA": "derived: the regional certificate bundle of the database service, as PEM",
|
|
6
|
-
"S3_ACCESS_KEY_ID": "derived: the access key id of the files user",
|
|
7
|
-
"S3_SECRET_ACCESS_KEY": "derived: its secret",
|
|
8
6
|
"TUNNEL_TOKEN": "derived: the connector token of the tunnel",
|
|
9
7
|
"GITHUB_CLIENT_SECRET": "asked: the client secret of the OAuth app, or later",
|
|
10
|
-
"GOOGLE_CLIENT_SECRET": "asked: the client secret of the OAuth client, or later"
|
|
8
|
+
"GOOGLE_CLIENT_SECRET": "asked: the client secret of the OAuth client, or later",
|
|
9
|
+
"S3_ACCESS_KEY_ID": "only with the bucket on R2 (sizing.md, Options): asked, the key made in the Cloudflare dashboard",
|
|
10
|
+
"S3_SECRET_ACCESS_KEY": "only with the bucket on R2: its secret"
|
|
11
11
|
}
|
|
@@ -9,7 +9,7 @@ What the agent needs to act on the organization's accounts, the exact steps of t
|
|
|
9
9
|
**The deployer user and its policy.** Signed in to the account, in the IAM console (checked 2026-10):
|
|
10
10
|
|
|
11
11
|
1. Users, Create user. Name `console-deployer`. Leave console access off: this user is for the agent's commands only. Next.
|
|
12
|
-
2. Permissions: Attach policies directly. In a dedicated account, `AdministratorAccess`, which is the honest choice when nobody else is in the account. In a shared account, first create a policy from [deployer-policy.json](../assets/deployer-policy.json) (Policies, Create policy, JSON, paste, name it `console-deployer`), then attach that one. The scoped policy is broad where names cannot be known in advance and tight where they can (`console-` prefixed roles
|
|
12
|
+
2. Permissions: Attach policies directly. In a dedicated account, `AdministratorAccess`, which is the honest choice when nobody else is in the account. In a shared account, first create a policy from [deployer-policy.json](../assets/deployer-policy.json) (Policies, Create policy, JSON, paste, name it `console-deployer`), then attach that one. The scoped policy is broad where names cannot be known in advance and tight where they can (`console-` prefixed roles and buckets, `/console/` parameters); a step the policy does not cover fails with `AccessDenied` naming the action, and the fix is to add that action.
|
|
13
13
|
3. Create user. Open it, Security credentials, Access keys, Create access key. Use case: Command Line Interface (and tick the confirmation), or Other. Next, Create access key. **Download .csv file.** The secret is shown once; the file is the one copy.
|
|
14
14
|
|
|
15
15
|
**How the key reaches the agent.** Save the downloaded file as `.aws-key.csv` in the run's folder, and tell the agent. The agent reads the two values from the file with a script, writes them as the profile `console` into the AWS CLI's own credentials file (`~/.aws/credentials`, the person's file, readable by them alone where the system can say so), sets the region on the profile, and deletes `.aws-key.csv`; neither value is ever written in a command line, a message or a log. A key pasted into the chat instead is written to the profile the same way and never repeated, and the person is told to make a new key once the run is over, since the chat kept a copy.
|
|
@@ -27,23 +27,24 @@ The first answers with the account id the plan names; the second with the defaul
|
|
|
27
27
|
|
|
28
28
|
## The Cloudflare account and the token
|
|
29
29
|
|
|
30
|
-
**The domain.** The console's address is a hostname of a zone on Cloudflare: a domain the organization already has, moved to Cloudflare's nameservers (the zone is made first in the run, and the two nameservers it answers go to the person to set at their registrar), or one bought through Cloudflare's Registrar in the run with its price shown first. There is no free hostname for a tunnel; a domain is needed. The account is free (`dash.cloudflare.com/sign-up`).
|
|
30
|
+
**The domain.** The console's address is a hostname of a zone on Cloudflare: a domain the organization already has, moved to Cloudflare's nameservers (the zone is made first in the run, and the two nameservers it answers go to the person to set at their registrar), or one bought through Cloudflare's Registrar in the run with its price shown first. There is no free hostname for a tunnel; a domain is needed. The account is free (`dash.cloudflare.com/sign-up`). A domain already on Cloudflare and serving the organization's site is the usual case and needs nothing moved: the run adds the console's hostname to that zone and touches nothing else of it (resources.md "A zone that serves other hosts").
|
|
31
31
|
|
|
32
|
-
**The nameservers, at the registrar.** The person named the registrar with the domain; give its steps where they are known (the registrar's own help page for "nameservers" or "DNS settings", read at run time), and otherwise the general ones: sign in at the registrar, open the domain, find the nameserver (NS) setting, replace what is there with the two names Cloudflare gave, save; the change takes minutes to hours to reach the world, and the run checks the zone's status until it is `active`. Everything else of the domain's DNS (mail records, other hosts)
|
|
32
|
+
**The nameservers, at the registrar.** The person named the registrar with the domain; give its steps where they are known (the registrar's own help page for "nameservers" or "DNS settings", read at run time), and otherwise the general ones: sign in at the registrar, open the domain, find the nameserver (NS) setting, replace what is there with the two names Cloudflare gave, save; the change takes minutes to hours to reach the world, and the run checks the zone's status until it is `active`. Everything else of the domain's DNS (mail records, other hosts) must be in the Cloudflare zone before the switch, so that nothing stops working: the run has Cloudflare scan for the common records, shows the person what it found, and asks them to compare with the registrar's DNS page and send what is missing (resources.md "The zone, first").
|
|
33
33
|
|
|
34
34
|
**The token.** My Profile, API Tokens (`dash.cloudflare.com/profile/api-tokens`), Create Token, **Create Custom Token** (checked 2026-10). Name it `console deploy`. Permissions, one row each:
|
|
35
35
|
|
|
36
36
|
| Scope | Permission | Level | For |
|
|
37
37
|
|---|---|---|---|
|
|
38
|
-
| Account | Cloudflare
|
|
39
|
-
| Account | Account Settings | Read |
|
|
38
|
+
| Account | Cloudflare One Connector: cloudflared | Edit | The tunnel the task connects out through, and its routing. The row was called "Cloudflare Tunnel" before and is listed as "Argo Tunnel (Legacy)" beside the new name, which still works; the API reports it as "Cloudflare Tunnel Write" (checked 2026-10) |
|
|
39
|
+
| Account | Account Settings | Read | The account id, only when the zone is made or a domain bought in the run; a zone that exists names its account itself |
|
|
40
40
|
| Zone | DNS | Edit | The console's hostname |
|
|
41
41
|
| Zone | Zone | Edit | Making the zone for a domain the organization owns, finding it, its status and nameservers |
|
|
42
|
-
| Zone | Zone Settings | Edit | Always use HTTPS |
|
|
42
|
+
| Zone | Zone Settings | Edit | Always use HTTPS, on a zone of the console's own |
|
|
43
|
+
| Zone | Dynamic URL Redirects | Edit | Always HTTPS for the console's hostname alone, on a zone that serves other hosts too (resources.md "A zone that serves other hosts") |
|
|
43
44
|
| Zone | Zone WAF | Edit | The rate limiting rule |
|
|
44
45
|
| Account | Registrar | Edit | Only when a domain is to be bought in the run |
|
|
45
46
|
|
|
46
|
-
Zone Resources: Include, All zones, so that a zone made in the run is covered. Account Resources: the one account. Client IP filtering: none. TTL: none, or a year. Continue to summary, Create Token, copy the secret once.
|
|
47
|
+
Zone Resources: Include, All zones, so that a zone made in the run is covered. Account Resources: the one account. Client IP filtering: none. TTL: none, or a year. Continue to summary, Create Token, copy the secret once. Read where Edit is needed is the mistake to look for: a token with Read lists tunnels and rules and fails only at the first creation, with `403` and code `10000` (resources.md "When a step fails").
|
|
47
48
|
|
|
48
49
|
**How it reaches the agent.** Save it in a file named `.cf-token` in the run's folder (a file without an extension: in Notepad, "All files" as the type, so that it is not `.cf-token.txt`), and tell the agent; the agent reads it into the environment for each call and never prints it. The file is listed in the deployment repository's `.gitignore` from the first commit, and deleted at the end of the run on the person's word. A token pasted into the chat is written into the file with the agent's file tool and never repeated, and the person is told to make a new token once the run is over, since the chat kept a copy.
|
|
49
50
|
|
|
@@ -56,7 +57,7 @@ curl -s https://api.cloudflare.com/client/v4/zones -H "Authorization: Bearer $CL
|
|
|
56
57
|
curl -s https://api.cloudflare.com/client/v4/accounts -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN"
|
|
57
58
|
```
|
|
58
59
|
|
|
59
|
-
`"status": "active"`, the zones the token sees (the domain among them, or none yet), and the account with its id. The file carries a carriage return on Windows; the `tr` strips it.
|
|
60
|
+
`"status": "active"`, the zones the token sees (the domain among them, or none yet), and the account with its id; the last call is refused without Account Settings Read, which is fine when the zone exists and carries the id. The file carries a carriage return on Windows; the `tr` strips it. These calls prove the token is live, not what it may change: Read lists exactly what Edit lists.
|
|
60
61
|
|
|
61
62
|
## The git host
|
|
62
63
|
|
|
@@ -75,4 +76,4 @@ An organization-owned app is the same form under the organization's settings, De
|
|
|
75
76
|
|
|
76
77
|
## The secrets file
|
|
77
78
|
|
|
78
|
-
What the person holds (a client secret) is written to a file outside any repository, in the shape of [secrets.example.json](../assets/secrets.example.json): `console-secrets.json` in the folder above the run's folder, with the generated and derived values the run adds to it. The run reads it to put each value in the parameter store, and deletes it at the end on the person's word. A value the person says `later` to leaves its provider out until it is set; the record names it. A provider the person did not choose has no line in it.
|
|
79
|
+
What the person holds (a client secret) is written to a file outside any repository, in the shape of [secrets.example.json](../assets/secrets.example.json): `console-secrets.json` in the folder above the run's folder, with the generated and derived values the run adds to it. The file starts empty: the script's `set` makes it with the first value, and the example is its shape, never a template to copy, since `store` would write a copied placeholder as a parameter. The run reads it to put each value in the parameter store, and deletes it at the end on the person's word. A value the person says `later` to leaves its provider out until it is set; the record names it. A provider the person did not choose has no line in it.
|
|
@@ -4,17 +4,19 @@ What each phase of the skill does, command by command, with its checks and what
|
|
|
4
4
|
|
|
5
5
|
## Conventions
|
|
6
6
|
|
|
7
|
-
The run's folder is the deployment repository to be; `scratch/` in it holds the scripts, the request bodies and the notes, and never enters git. Every AWS command runs as the deployer, in the region of the plan; every Cloudflare call carries the token from its file. Set once per shell, in the run's folder:
|
|
7
|
+
The run's folder is the deployment repository to be; `scratch/` in it holds the scripts, the request bodies and the notes, and never enters git. Every AWS command runs as the deployer, in the region of the plan; every Cloudflare call carries the token from its file. Set once per shell, in the run's folder (the shell is bash: Git Bash on Windows, bash or zsh elsewhere):
|
|
8
8
|
|
|
9
|
-
```
|
|
9
|
+
```bash
|
|
10
10
|
export MSYS_NO_PATHCONV=1 # Git Bash on Windows: keep /console and the like as written
|
|
11
11
|
export AWS_PROFILE=console AWS_REGION=<region>
|
|
12
12
|
export CLOUDFLARE_API_TOKEN=$(tr -d '\r\n ' < .cf-token)
|
|
13
13
|
cf() { curl -sS "https://api.cloudflare.com/client/v4$1" -H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" -H "Content-Type: application/json" "${@:2}"; }
|
|
14
14
|
ACCOUNT=$(aws sts get-caller-identity --query Account --output text)
|
|
15
|
-
SECRETS=../console-secrets.json # outside the repository
|
|
15
|
+
SECRETS=../console-secrets.json # outside the repository; made by the first `set` below, never a copy of assets/secrets.example.json
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
+
Every id the run makes or finds (`CF_ACCOUNT`, `ZONE`, `VPC`, `SUBNETS`, `TASKS_SG`, `DB_SG`, `ENDPOINT`, `BUCKET`, `TUNNEL`, the rules' ids) goes into `scratch/notes.md` as it is made, as a line `NAME=value`, and a new shell sets them again from there (`set -a; . scratch/notes.md; set +a` when the file holds nothing but such lines): phases 5 to 7 come after a ten-minute watch, or after a pause of hours, almost always in a new shell.
|
|
19
|
+
|
|
18
20
|
A value that must not be seen goes through a file or a pipe, never a command line: `aws ... --cli-input-json file://scratch/<file>` for a request that carries one, and the two scripts of this skill ([import-aws-key.mjs](../scripts/import-aws-key.mjs), [store-secrets.mjs](../scripts/store-secrets.mjs)), copied to `scratch/`, for the key and the parameters. A response that carries one is piped straight into the secrets file:
|
|
19
21
|
|
|
20
22
|
```sh
|
|
@@ -24,12 +26,12 @@ node -e "process.stdout.write(require('node:crypto').randomBytes(36).toString('b
|
|
|
24
26
|
|
|
25
27
|
## Phase 1: Access
|
|
26
28
|
|
|
27
|
-
1. The folder: `mkdir -p <folder>/scratch && cd <folder> && git init -b main`, and `.gitignore` with `.cf-token`, `.aws-key.csv`, `scratch/`, `*.pem`, `console-secrets.json`, `node_modules/`, `console-ui/dist/`, `.env`, `.env.*`, before anything else is written.
|
|
29
|
+
1. The folder: `mkdir -p <folder>/scratch && cd <folder> && git init -b main`, and `.gitignore` with `.cf-token`, `.aws-key.csv`, `scratch/`, `*.pem`, `console-secrets.json`, `node_modules/`, `console-ui/dist/`, `.env`, `.env.*`, `.skillcdn-console.json` (which `console use` writes in phase 6, and which belongs to a checkout people work in, not to the deployment), before anything else is written.
|
|
28
30
|
2. The command and the scripts: `npm install -g @skillcdn/console`, then copy `$(npm root -g)/@skillcdn/console/skills/deploying-a-console/scripts/*.mjs` into `scratch/` (or write them there from the connection's copy of the skill).
|
|
29
31
|
3. The key: the person saved `.aws-key.csv` in the folder ([access.md](access.md)). `node scratch/import-aws-key.mjs .aws-key.csv console` writes the profile and deletes the file. Then `aws configure set region <region> --profile console`.
|
|
30
32
|
4. `aws sts get-caller-identity`: the account id and `user/console-deployer`. A refusal means the key is wrong or not yet active (a minute after creation).
|
|
31
33
|
5. The network: `aws ec2 describe-vpcs --filters Name=is-default,Values=true --query 'Vpcs[0].VpcId' --output text`. `None`: `aws ec2 create-default-vpc`. Then the subnets: `aws ec2 describe-subnets --filters Name=vpc-id,Values=<vpc> Name=default-for-az,Values=true --query 'Subnets[].SubnetId' --output text`.
|
|
32
|
-
6. The token: `cf /user/tokens/verify` answers `"status": "active"`; `cf /
|
|
34
|
+
6. The token: `cf /user/tokens/verify` answers `"status": "active"`; `cf "/zones?name=<domain>" > scratch/zone.json` the zone, with its id, its `status` and its account (`ZONE=$(jq -r '.result[0].id' scratch/zone.json)`, `CF_ACCOUNT=$(jq -r '.result[0].account.id' scratch/zone.json)`), or an empty list when the domain is not on Cloudflare yet, in which case `CF_ACCOUNT=$(cf /accounts | jq -r '.result[0].id')` and `ZONE` waits for phase 3. A zone that exists: `cf "/zones/$ZONE/dns_records" | jq '.result | length'`; records of its own mean the zone serves the organization's site already, which the access record says and phase 3 "A zone that serves other hosts" acts on.
|
|
33
35
|
7. The git host: `gh auth status`; the owner the person named is theirs or an organization they are in (`gh api user`, `gh api user/orgs`).
|
|
34
36
|
8. The tools: `node --version` (24 or newer), `git --version`, `jq --version`, `curl --version`.
|
|
35
37
|
|
|
@@ -43,8 +45,8 @@ Nothing is created. The size from the answer and its numbers ([sizing.md](sizing
|
|
|
43
45
|
|---|---|
|
|
44
46
|
| Cluster; services; task families | `console`; `api`, `worker`; `console-api`, `console-worker`, `console-migrate` |
|
|
45
47
|
| Database instance, subnet group, user, database | `console`, `console`, `console`, `console` |
|
|
46
|
-
| Bucket
|
|
47
|
-
| Image repository, log group | `console`, `/console` |
|
|
48
|
+
| Bucket | `console-files-<account id>` (bucket names are global; the account id makes it unique), read and written by the task's role |
|
|
49
|
+
| Image repository, log group | `console`, `/console`; the connector's image in the same repository under `cloudflared-<version>` |
|
|
48
50
|
| Roles | `console-task-execution`, `console-task`, `console-github-deploy` |
|
|
49
51
|
| Security groups | `console-tasks`, `console-db` |
|
|
50
52
|
| Parameters | `/console/<NAME>`, one per secret of the secrets file |
|
|
@@ -57,7 +59,7 @@ The plan message is the estimate message of sizing.md followed by the line in th
|
|
|
57
59
|
|
|
58
60
|
### The zone, first
|
|
59
61
|
|
|
60
|
-
Not on Cloudflare yet: `cf /zones -X POST --data '{"name":"<domain>","account":{"id":"
|
|
62
|
+
Not on Cloudflare yet: `cf /zones -X POST --data '{"name":"<domain>","account":{"id":"'$CF_ACCOUNT'"},"type":"full"}'`, then `ZONE=$(cf "/zones?name=<domain>" | jq -r '.result[0].id')`. Before the nameservers change, the zone must carry what the domain serves already (its site, its mail), or that stops working at the switch: `cf "/zones/$ZONE/dns_records/scan" -X POST` has Cloudflare find the common records and add them (checked 2026-10), `cf "/zones/$ZONE/dns_records" | jq -r '.result[] | "\(.type) \(.name) \(.content)"'` lists what the zone now has, and the list goes to the person with the nameserver steps: "Compare this with the DNS page at your registrar and send me anything that is missing", each missing one added with `cf "/zones/$ZONE/dns_records" -X POST --data '{"type":"<MX | A | CNAME | TXT>","name":"<name>","content":"<value>","priority":<MX only>,"ttl":1}'`. The answer's `name_servers` go to the person with the steps for their registrar (access.md "The domain"), the run goes on, and the hostname answers once `cf "/zones?name=<domain>"` says `"status": "active"`. A domain to buy: `cf "/accounts/$CF_ACCOUNT/registrar/domain-search?q=<words>"` to find one, `cf "/accounts/$CF_ACCOUNT/registrar/domain-check" -X POST --data '{"domains":["<domain>"]}'` for the live price, the checkpoint with the price and the renewal, then `cf "/accounts/$CF_ACCOUNT/registrar/registrations" -X POST --data '{"domain_name":"<domain>"}'`; a `202` is polled at `.../registrations/<domain>/registration-status`. Buying through the API was in beta when checked (2026-10) and is not open to every account: `extension_not_supported_via_api`, a `404` or a route error means the person buys in the dashboard (Domain Registration, Register Domains), with the price in front of them there. A bought domain's zone is active at once, and `ZONE` is read as above.
|
|
61
63
|
|
|
62
64
|
### The network
|
|
63
65
|
|
|
@@ -68,7 +70,7 @@ DB_SG=$(aws ec2 create-security-group --group-name console-db --description "con
|
|
|
68
70
|
aws ec2 authorize-security-group-ingress --group-id $DB_SG --protocol tcp --port 5432 --source-group $TASKS_SG
|
|
69
71
|
```
|
|
70
72
|
|
|
71
|
-
A new group allows all egress and no ingress; the tasks' group stays so. Check: `aws ec2 describe-security-groups --group-ids $TASKS_SG --query 'SecurityGroups[0].IpPermissions'` is `[]`.
|
|
73
|
+
A new group allows all egress and no ingress; the tasks' group stays so. Check: `aws ec2 describe-security-groups --group-ids $TASKS_SG --query 'SecurityGroups[0].IpPermissions'` is `[]`. What the tasks need out, for an organization that restricts egress later: the connector reaches Cloudflare on `7844` (UDP first, then TCP) and `443`; the console reaches the database on `5432`, and the bucket, the sign-in providers and SkillCDN on `443`.
|
|
72
74
|
|
|
73
75
|
### The database
|
|
74
76
|
|
|
@@ -119,18 +121,15 @@ node scratch/store-secrets.mjs derive "$SECRETS" DATABASE_URL "postgres://consol
|
|
|
119
121
|
|
|
120
122
|
The regional bundle holds the three authorities a database in the region may be signed by (about 4.5 KB, read 2026-10), so it covers the instance whatever authority it got; it goes in as `DATABASE_CA`, and the URL carries no `ssl` parameter of its own (the console refuses both together). The password is base64url, so the URL needs no encoding.
|
|
121
123
|
|
|
122
|
-
### The bucket
|
|
124
|
+
### The bucket
|
|
123
125
|
|
|
124
126
|
```sh
|
|
125
127
|
BUCKET=console-files-$ACCOUNT
|
|
126
128
|
aws s3api create-bucket --bucket $BUCKET --region $AWS_REGION --create-bucket-configuration LocationConstraint=$AWS_REGION # us-east-1: without the configuration
|
|
127
129
|
aws s3api put-public-access-block --bucket $BUCKET --public-access-block-configuration BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true
|
|
128
|
-
aws iam create-user --user-name console-files --tags Key=app,Value=console
|
|
129
|
-
aws iam put-user-policy --user-name console-files --policy-name console-files --policy-document file://scratch/files-policy.json
|
|
130
|
-
node scratch/store-secrets.mjs key "$SECRETS" console-files # makes the key once, stores both halves, prints neither
|
|
131
130
|
```
|
|
132
131
|
|
|
133
|
-
`scratch/files-policy.json`:
|
|
132
|
+
No user and no key: the tasks read and write the bucket as their own role, whose credentials the platform hands them and renews (`S3_CREDENTIALS=platform` in the task definition; `deploy/README.md` "The bucket"). The role gets the policy below when it is made. `scratch/files-policy.json`:
|
|
134
133
|
|
|
135
134
|
```json
|
|
136
135
|
{
|
|
@@ -146,6 +145,8 @@ node scratch/store-secrets.mjs key "$SECRETS" console-files # makes the key
|
|
|
146
145
|
|
|
147
146
|
### The image repository, the logs, the roles, the cluster
|
|
148
147
|
|
|
148
|
+
Two files first. `scratch/ecs-trust.json` lets ECS tasks assume a role: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"ecs-tasks.amazonaws.com"},"Action":"sts:AssumeRole"}]}`. `scratch/parameters-policy.json` lets the execution role read the console's parameters: `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["ssm:GetParameters"],"Resource":"arn:aws:ssm:<region>:<account>:parameter/console/*"}]}` (the parameters use the account's default key, which needs no `kms:Decrypt` of its own; checked 2026-10). Then:
|
|
149
|
+
|
|
149
150
|
```sh
|
|
150
151
|
aws ecr create-repository --repository-name console --image-scanning-configuration scanOnPush=true --image-tag-mutability IMMUTABLE
|
|
151
152
|
aws logs create-log-group --log-group-name /console && aws logs put-retention-policy --log-group-name /console --retention-in-days 30
|
|
@@ -153,23 +154,24 @@ aws iam create-role --role-name console-task-execution --assume-role-policy-docu
|
|
|
153
154
|
aws iam attach-role-policy --role-name console-task-execution --policy-arn arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy
|
|
154
155
|
aws iam put-role-policy --role-name console-task-execution --policy-name console-parameters --policy-document file://scratch/parameters-policy.json
|
|
155
156
|
aws iam create-role --role-name console-task --assume-role-policy-document file://scratch/ecs-trust.json
|
|
157
|
+
aws iam put-role-policy --role-name console-task --policy-name console-files --policy-document file://scratch/files-policy.json
|
|
156
158
|
aws iam create-service-linked-role --aws-service-name ecs.amazonaws.com # "has been taken" means it exists: fine
|
|
157
159
|
aws ecs create-cluster --cluster-name console --capacity-providers FARGATE --default-capacity-provider-strategy capacityProvider=FARGATE,weight=1
|
|
158
160
|
```
|
|
159
161
|
|
|
160
|
-
|
|
162
|
+
The task role carries the files policy and nothing else: the platform hands every container of the task the role's credentials at an address it puts in the environment, and the console reads them there.
|
|
161
163
|
|
|
162
164
|
### The tunnel, the hostname and the rules at Cloudflare
|
|
163
165
|
|
|
164
|
-
1. **The tunnel.** `cf "/accounts
|
|
166
|
+
1. **The tunnel.** `cf "/accounts/$CF_ACCOUNT/cfd_tunnel" -X POST --data '{"name":"console","config_src":"cloudflare"}' > scratch/tunnel.json`; `TUNNEL=$(jq -r .result.id scratch/tunnel.json)`; `jq -r .result.token scratch/tunnel.json | set_secret TUNNEL_TOKEN`; `rm scratch/tunnel.json`. Then its routing: `cf "/accounts/$CF_ACCOUNT/cfd_tunnel/$TUNNEL/configurations" -X PUT --data '{"config":{"ingress":[{"hostname":"<hostname>","service":"http://127.0.0.1:11199"},{"service":"http_status:404"}]}}'`. The connector runs beside the `api` container, so the service is the loopback, by its address rather than `localhost`, which may resolve to `::1` and reach the console from there; the last rule, without a hostname, is required.
|
|
165
167
|
2. **The hostname.** `cf "/zones/$ZONE/dns_records" -X POST --data '{"type":"CNAME","name":"<hostname>","content":"'$TUNNEL'.cfargotunnel.com","proxied":true,"ttl":1}'`. Proxied: the traffic goes through Cloudflare, which terminates TLS with its own certificate for the zone.
|
|
166
|
-
3. **
|
|
167
|
-
4. **The rate limiting rule**, on the sign-in and connect paths, which is what the console asks of a proxy; the board's own traffic is not limited. `RULESET=$(cf "/zones/$ZONE/rulesets/phases/http_ratelimit/entrypoint" | jq -r '.result.id')`: the entry point ruleset's id, or `null` when the zone has none yet. With one, `cf "/zones/$ZONE/rulesets/$RULESET/rules" -X POST --data @scratch/rule.json
|
|
168
|
+
3. **Always HTTPS**, so that `http://` turns into `https://`, which `PUBLIC_URL` is. On a zone of the console's own: `cf "/zones/$ZONE/settings/always_use_https" -X PATCH --data '{"value":"on"}'`. On a zone that serves other hosts: the redirect rule of "A zone that serves other hosts" below, for the hostname alone.
|
|
169
|
+
4. **The rate limiting rule**, on the sign-in and connect paths of the console's hostname, which is what the console asks of a proxy; the board's own traffic is not limited. `RULESET=$(cf "/zones/$ZONE/rulesets/phases/http_ratelimit/entrypoint" | jq -r '.result.id')`: the entry point ruleset's id, or `null` when the zone has none yet. With one, `cf "/zones/$ZONE/rulesets/$RULESET/rules" -X POST --data @scratch/rule.json`, which adds the rule and leaves the zone's others alone; with none, `cf "/zones/$ZONE/rulesets" -X POST --data '{"name":"console","kind":"zone","phase":"http_ratelimit","rules":[<the rule>]}'`. Either answer carries the ruleset with its rules: the ruleset's id and the rule's (`.result.id`, `.result.rules[-1].id`) go into the notes and the record. Never `PUT` on the entry point: it replaces every rule of the phase. `scratch/rule.json`:
|
|
168
170
|
|
|
169
171
|
```json
|
|
170
172
|
{
|
|
171
173
|
"description": "console: sign-in and connect, per client",
|
|
172
|
-
"expression": "(starts_with(http.request.uri.path, \"/auth/\") or starts_with(http.request.uri.path, \"/api/v1/connect\"))",
|
|
174
|
+
"expression": "(http.host eq \"<hostname>\" and (starts_with(http.request.uri.path, \"/auth/\") or starts_with(http.request.uri.path, \"/api/v1/connect\")))",
|
|
173
175
|
"action": "block",
|
|
174
176
|
"enabled": true,
|
|
175
177
|
"ratelimit": { "characteristics": ["ip.src", "cf.colo.id"], "period": 10, "requests_per_period": 30, "mitigation_timeout": 10 }
|
|
@@ -178,9 +180,25 @@ aws ecs create-cluster --cluster-name console --capacity-providers FARGATE --def
|
|
|
178
180
|
|
|
179
181
|
The Free plan allows one rate limiting rule, with a ten-second window and a ten-second block (checked 2026-10); a paid plan allows more, with longer windows.
|
|
180
182
|
|
|
183
|
+
### A zone that serves other hosts
|
|
184
|
+
|
|
185
|
+
The organization's site on the same domain is the usual case: the zone has records of its own (phase 1), and the run changes nothing zone-wide. The hostname's record and the rate limiting rule above name the hostname, so they touch nothing of the site. Always HTTPS is a setting of the whole zone, so for the hostname alone it is a redirect rule instead, in the phase `http_request_dynamic_redirect`, added the same way as the rate limiting rule (`RULESET=$(cf "/zones/$ZONE/rulesets/phases/http_request_dynamic_redirect/entrypoint" | jq -r '.result.id')`, then a `POST` of the rule to it, or a new ruleset of that phase with it), with the token's "Dynamic URL Redirects: Edit" (access.md). `scratch/redirect.json`:
|
|
186
|
+
|
|
187
|
+
```json
|
|
188
|
+
{
|
|
189
|
+
"description": "console: https only",
|
|
190
|
+
"expression": "(http.host eq \"<hostname>\" and not ssl)",
|
|
191
|
+
"action": "redirect",
|
|
192
|
+
"action_parameters": { "from_value": { "status_code": 301, "target_url": { "expression": "concat(\"https://\", http.host, http.request.uri.path)" }, "preserve_query_string": true } },
|
|
193
|
+
"enabled": true
|
|
194
|
+
}
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
When the zone's `always_use_https` is `on` already (`cf "/zones/$ZONE/settings/always_use_https"`), the site has it and the console gets it with the rest: no rule is needed, and the record says so. A rule made here goes into the notes and the record with its ruleset's id and its own, as the rate limiting rule does. Other per-host settings of the site (its own rules, its integrity checks, its obfuscation) are the site's: the run neither reads nor changes them.
|
|
198
|
+
|
|
181
199
|
### The parameters
|
|
182
200
|
|
|
183
|
-
|
|
201
|
+
The generated one first, if the Conventions' example was not run as a step: `node -e "process.stdout.write(require('node:crypto').randomBytes(36).toString('base64url'))" | set_secret AUTH_SECRET`. With it, the database's URL and authority and the tunnel's token in the secrets file: `node scratch/store-secrets.mjs store "$SECRETS" /console` writes each as a `SecureString` parameter `/console/<NAME>` (the certificate bundle in the advanced tier, which takes up to 8 KB; the rest standard), skipping `later` and the password that is already inside the URL. Check: `aws ssm describe-parameters --parameter-filters Key=Name,Option=BeginsWith,Values=/console/ --query 'Parameters[].Name'`.
|
|
184
202
|
|
|
185
203
|
### The secrets the person holds
|
|
186
204
|
|
|
@@ -194,7 +212,8 @@ In the run's folder, write:
|
|
|
194
212
|
|
|
195
213
|
```sh
|
|
196
214
|
CONSOLE_REPOSITORY=https://github.com/skillcdn/console.git
|
|
197
|
-
CONSOLE_REF=<the commit of
|
|
215
|
+
CONSOLE_REF=<the commit of the release tag pinned at the plan>
|
|
216
|
+
CLOUDFLARED_VERSION=<the connector's release, such as 2026.10.0, from github.com/cloudflare/cloudflared/releases>
|
|
198
217
|
AWS_ACCOUNT_ID=<account id>
|
|
199
218
|
AWS_REGION=<region>
|
|
200
219
|
ECR_REPOSITORY=console
|
|
@@ -210,7 +229,7 @@ PLATFORM=linux/amd64
|
|
|
210
229
|
|
|
211
230
|
### `deploy/api.task.json`
|
|
212
231
|
|
|
213
|
-
The task definition of the `api` role with the tunnel's connector beside it, in the shape `register-task-definition` takes; the workflow fills `image
|
|
232
|
+
The task definition of the `api` role with the tunnel's connector beside it, in the shape `register-task-definition` takes; the workflow fills both `image` fields. At `small` and `medium`, `WORKER_IN_PROCESS` is `true` and there is no `worker.task.json`; at `large`, it is left out and the worker is a service of its own.
|
|
214
233
|
|
|
215
234
|
```json
|
|
216
235
|
{
|
|
@@ -228,12 +247,13 @@ The task definition of the `api` role with the tunnel's connector beside it, in
|
|
|
228
247
|
"image": "<image>",
|
|
229
248
|
"essential": true,
|
|
230
249
|
"command": ["api"],
|
|
250
|
+
"readonlyRootFilesystem": true,
|
|
231
251
|
"portMappings": [{ "containerPort": 11199, "protocol": "tcp" }],
|
|
232
252
|
"environment": [
|
|
233
253
|
{ "name": "PUBLIC_URL", "value": "https://<hostname>" },
|
|
234
254
|
{ "name": "WORKSPACE_NAME", "value": "<the board's name>" },
|
|
235
255
|
{ "name": "WORKER_IN_PROCESS", "value": "true" },
|
|
236
|
-
{ "name": "TRUSTED_PROXIES", "value": "127.0.0.1" },
|
|
256
|
+
{ "name": "TRUSTED_PROXIES", "value": "127.0.0.1,::1" },
|
|
237
257
|
{ "name": "CLIENT_IP_HEADER", "value": "cf-connecting-ip" },
|
|
238
258
|
{ "name": "HTTP_KEEP_ALIVE_SECONDS", "value": "100" },
|
|
239
259
|
{ "name": "MEMBERS", "value": "<logins and addresses, comma-separated>" },
|
|
@@ -242,15 +262,14 @@ The task definition of the `api` role with the tunnel's connector beside it, in
|
|
|
242
262
|
{ "name": "SKILLS_ADDRESS", "value": "<the organization's skills address, or drop the line>" },
|
|
243
263
|
{ "name": "S3_ENDPOINT", "value": "https://s3.<region>.amazonaws.com" },
|
|
244
264
|
{ "name": "S3_BUCKET", "value": "console-files-<account>" },
|
|
245
|
-
{ "name": "S3_REGION", "value": "<region>" }
|
|
265
|
+
{ "name": "S3_REGION", "value": "<region>" },
|
|
266
|
+
{ "name": "S3_CREDENTIALS", "value": "platform" }
|
|
246
267
|
],
|
|
247
268
|
"secrets": [
|
|
248
269
|
{ "name": "DATABASE_URL", "valueFrom": "arn:aws:ssm:<region>:<account>:parameter/console/DATABASE_URL" },
|
|
249
270
|
{ "name": "DATABASE_CA", "valueFrom": "arn:aws:ssm:<region>:<account>:parameter/console/DATABASE_CA" },
|
|
250
271
|
{ "name": "AUTH_SECRET", "valueFrom": "arn:aws:ssm:<region>:<account>:parameter/console/AUTH_SECRET" },
|
|
251
|
-
{ "name": "GITHUB_CLIENT_SECRET", "valueFrom": "arn:aws:ssm:<region>:<account>:parameter/console/GITHUB_CLIENT_SECRET" }
|
|
252
|
-
{ "name": "S3_ACCESS_KEY_ID", "valueFrom": "arn:aws:ssm:<region>:<account>:parameter/console/S3_ACCESS_KEY_ID" },
|
|
253
|
-
{ "name": "S3_SECRET_ACCESS_KEY", "valueFrom": "arn:aws:ssm:<region>:<account>:parameter/console/S3_SECRET_ACCESS_KEY" }
|
|
272
|
+
{ "name": "GITHUB_CLIENT_SECRET", "valueFrom": "arn:aws:ssm:<region>:<account>:parameter/console/GITHUB_CLIENT_SECRET" }
|
|
254
273
|
],
|
|
255
274
|
"healthCheck": {
|
|
256
275
|
"command": ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:11199/readyz').then((r) => process.exit(r.ok ? 0 : 1), () => process.exit(1))\""],
|
|
@@ -264,7 +283,7 @@ The task definition of the `api` role with the tunnel's connector beside it, in
|
|
|
264
283
|
},
|
|
265
284
|
{
|
|
266
285
|
"name": "tunnel",
|
|
267
|
-
"image": "
|
|
286
|
+
"image": "<the workflow fills it: the connector's image, mirrored into the repository>",
|
|
268
287
|
"essential": true,
|
|
269
288
|
"command": ["tunnel", "--no-autoupdate", "run"],
|
|
270
289
|
"secrets": [{ "name": "TUNNEL_TOKEN", "valueFrom": "arn:aws:ssm:<region>:<account>:parameter/console/TUNNEL_TOKEN" }],
|
|
@@ -276,15 +295,17 @@ The task definition of the `api` role with the tunnel's connector beside it, in
|
|
|
276
295
|
}
|
|
277
296
|
```
|
|
278
297
|
|
|
279
|
-
With Google: `GOOGLE_CLIENT_ID` and `GOOGLE_WORKSPACE_DOMAIN` in `environment`, `GOOGLE_CLIENT_SECRET` in `secrets`; without GitHub, the two GitHub entries are dropped. The connector starts only once the console is ready (`dependsOn` on the health check, which is `/readyz`), and stops first when the task stops, so a rollout never routes to a task that is not serving; the console's `stopTimeout` of 30 seconds is above its grace period of 20. The console sees every request from the loopback with the client's address in `cf-connecting-ip`, which is why the trusted
|
|
298
|
+
With Google: `GOOGLE_CLIENT_ID` and `GOOGLE_WORKSPACE_DOMAIN` in `environment`, `GOOGLE_CLIENT_SECRET` in `secrets`; without GitHub, the two GitHub entries are dropped. The connector starts only once the console is ready (`dependsOn` on the health check, which is `/readyz`), and stops first when the task stops, so a rollout never routes to a task that is not serving; the console's `stopTimeout` of 30 seconds is above its grace period of 20. The console sees every request from the loopback with the client's address in `cf-connecting-ip`, which is why the trusted proxies are the loopback's two addresses; its keep-alive of 100 seconds outlasts the connector's idle timeout of 90 (checked 2026-10). The image writes nothing to its filesystem, so the root filesystem is read-only (`deploy/README.md` "The image"); the connector's is not, since it is not the console's image. The image is the Node.js base, which has no `curl`, so the health check uses Node itself. The bucket's credentials are the task's role's, which the platform hands the container at an address it sets in the environment (`AWS_CONTAINER_CREDENTIALS_RELATIVE_URI`); nothing of that is written here.
|
|
280
299
|
|
|
281
300
|
### `deploy/worker.task.json` (at `large`)
|
|
282
301
|
|
|
283
|
-
The same with `"family": "console-worker"`, `"cpu": "256"`, `"memory": "512"`, one container `worker` with `"command": ["worker"]
|
|
302
|
+
The same with `"family": "console-worker"`, `"cpu": "256"`, `"memory": "512"`, one container `worker` with `"command": ["worker"]` and the read-only root filesystem, the `environment` reduced to `WORKSPACE_NAME`, the `secrets` to `DATABASE_URL` and `DATABASE_CA`, no port, no health check, `"stopTimeout": 30`, the log stream prefix `worker`, and no tunnel.
|
|
284
303
|
|
|
285
304
|
### `deploy/migrate.task.json`
|
|
286
305
|
|
|
287
|
-
The same with `"family": "console-migrate"`, `"cpu": "256"`, `"memory": "512"`, one container `migrate` with `"command": ["migrate"]
|
|
306
|
+
The same with `"family": "console-migrate"`, `"cpu": "256"`, `"memory": "512"`, one container `migrate` with `"command": ["migrate"]` and the read-only root filesystem, `secrets` `DATABASE_URL` and `DATABASE_CA`, no environment, no port, no health check, the log stream prefix `migrate`, and no tunnel: a connector must never start for a one-off task.
|
|
307
|
+
|
|
308
|
+
**Without a pipeline.** A deployment that is rolled by hand, or on a platform that runs one task definition and nothing before it, can run `migrate` inside the tasks instead of as a one-off: a container `migrate` in `api.task.json`, and at `large` in `worker.task.json` too, from the same image with `"command": ["migrate"]`, `"essential": false` and the `secrets` of the migrate task, and the role's own container with `"dependsOn": [{ "containerName": "migrate", "condition": "SUCCESS" }]` (the tunnel keeps its `dependsOn` on `api`). Every task of every role then migrates before it serves; tasks that start together wait on the advisory lock the migration takes, so none races another; a migration that fails leaves the role's container unstarted and the circuit breaker rolls the deployment back. The contract then holds per task rather than per rollout, which hard rule 8 allows in these words. The one-off task stays the default here because it stops the rollout before a single task is replaced.
|
|
288
309
|
|
|
289
310
|
### `.github/workflows/deploy.yml`
|
|
290
311
|
|
|
@@ -312,6 +333,15 @@ jobs:
|
|
|
312
333
|
aws-region: ${{ env.AWS_REGION }}
|
|
313
334
|
- uses: aws-actions/amazon-ecr-login@v2
|
|
314
335
|
id: ecr
|
|
336
|
+
- name: Mirror the connector's image, once per version
|
|
337
|
+
run: |
|
|
338
|
+
TUNNEL_IMAGE="${{ steps.ecr.outputs.registry }}/$ECR_REPOSITORY:cloudflared-$CLOUDFLARED_VERSION"
|
|
339
|
+
if ! aws ecr describe-images --repository-name "$ECR_REPOSITORY" --image-ids imageTag="cloudflared-$CLOUDFLARED_VERSION" > /dev/null 2>&1; then
|
|
340
|
+
docker pull --platform "$PLATFORM" "cloudflare/cloudflared:$CLOUDFLARED_VERSION"
|
|
341
|
+
docker tag "cloudflare/cloudflared:$CLOUDFLARED_VERSION" "$TUNNEL_IMAGE"
|
|
342
|
+
docker push "$TUNNEL_IMAGE"
|
|
343
|
+
fi
|
|
344
|
+
echo "TUNNEL_IMAGE=$TUNNEL_IMAGE" >> "$GITHUB_ENV"
|
|
315
345
|
- name: Get the console at the pinned commit
|
|
316
346
|
run: |
|
|
317
347
|
git clone --no-checkout "$CONSOLE_REPOSITORY" console
|
|
@@ -330,7 +360,7 @@ jobs:
|
|
|
330
360
|
run: |
|
|
331
361
|
for role in migrate api worker; do
|
|
332
362
|
[ -f "deploy/$role.task.json" ] || continue
|
|
333
|
-
jq --arg image "$IMAGE" '(.containerDefinitions[] | select(.name != "tunnel") | .image) = $image' "deploy/$role.task.json" > "$RUNNER_TEMP/$role.json"
|
|
363
|
+
jq --arg image "$IMAGE" --arg tunnel "$TUNNEL_IMAGE" '(.containerDefinitions[] | select(.name != "tunnel") | .image) = $image | (.containerDefinitions[] | select(.name == "tunnel") | .image) = $tunnel' "deploy/$role.task.json" > "$RUNNER_TEMP/$role.json"
|
|
334
364
|
aws ecs register-task-definition --cli-input-json "file://$RUNNER_TEMP/$role.json" --query 'taskDefinition.taskDefinitionArn' --output text
|
|
335
365
|
done
|
|
336
366
|
- name: Migrate, before anything new starts
|
|
@@ -353,10 +383,24 @@ jobs:
|
|
|
353
383
|
aws ecs create-service --cluster "$ECS_CLUSTER" --service-name "$role" --task-definition "console-$role" --desired-count "$COUNT" --launch-type FARGATE --network-configuration "$NETWORK" --deployment-configuration "minimumHealthyPercent=100,maximumPercent=200,deploymentCircuitBreaker={enable=true,rollback=true}" > /dev/null
|
|
354
384
|
fi
|
|
355
385
|
done
|
|
356
|
-
|
|
386
|
+
- name: Wait for the rollout
|
|
387
|
+
run: |
|
|
388
|
+
for role in api worker; do
|
|
389
|
+
[ -f "deploy/$role.task.json" ] || continue
|
|
390
|
+
WANTED=$(aws ecs describe-task-definition --task-definition "console-$role" --query 'taskDefinition.taskDefinitionArn' --output text)
|
|
391
|
+
for i in $(seq 1 60); do
|
|
392
|
+
DEPLOYED=$(aws ecs describe-services --cluster "$ECS_CLUSTER" --services "$role" --query 'services[0].deployments[0]' --output json | jq -r '"\(.taskDefinition) \(.rolloutState)"')
|
|
393
|
+
case "$DEPLOYED" in
|
|
394
|
+
"$WANTED COMPLETED") echo "$role: rolled out"; continue 2 ;;
|
|
395
|
+
"$WANTED IN_PROGRESS") sleep 15 ;;
|
|
396
|
+
*) echo "$role: the rollout failed ($DEPLOYED): a rollback is a deployment of the previous definition"; aws logs tail /console --since 15m --format short --log-stream-name-prefix "$role" || true; exit 1 ;;
|
|
397
|
+
esac
|
|
398
|
+
done
|
|
399
|
+
echo "$role: not rolled out after 15 minutes"; exit 1
|
|
400
|
+
done
|
|
357
401
|
```
|
|
358
402
|
|
|
359
|
-
What it does on every push that touches `deploy/` or `console-ui/`: builds the image from the console's pinned commit (and, when `console-ui/` exists, the organization's own console on top of it, as `deploy/README.md` "A custom console in the image" says), pushes it under a tag of its own (the console's commit, the repository's commit and the run's number, so that a run on the same commit still gets a new tag), registers the task definitions with
|
|
403
|
+
What it does on every push that touches `deploy/` or `console-ui/`: mirrors the connector's image into the repository the first time a version is seen, so that the tasks never pull from elsewhere; builds the image from the console's pinned commit (and, when `console-ui/` exists, the organization's own console on top of it, as `deploy/README.md` "A custom console in the image" says), pushes it under a tag of its own (the console's commit, the repository's commit and the run's number, so that a run on the same commit still gets a new tag), registers the task definitions with the two images, runs `migrate` as a one-off task and stops if it did not exit `0`, then rolls `api` (and `worker`) with a circuit breaker that rolls back a deployment whose tasks never become healthy, and waits for the rollout of the task definition it registered to be `COMPLETED` (`aws ecs wait services-stable` returns as soon as the running count matches, before the tasks are healthy, which on a first deployment is too early; and a rollback is a new deployment of the previous definition, which completes too, so the definition is compared, not the state alone). The first push creates the services. Pin the actions' majors to the current ones (`checkout@v4`, `configure-aws-credentials@v4`, `amazon-ecr-login@v2`, checked 2026-10; the actions' pages say when a newer major exists).
|
|
360
404
|
|
|
361
405
|
### The role the workflow assumes
|
|
362
406
|
|
|
@@ -399,13 +443,15 @@ aws iam put-role-policy --role-name console-github-deploy --policy-name console-
|
|
|
399
443
|
|
|
400
444
|
```sh
|
|
401
445
|
cf "/zones?name=<domain>" | jq -r '.result[0].status' # active; else the wait of the skill
|
|
402
|
-
curl -sS -o /
|
|
446
|
+
curl -sS -o scratch/readyz.txt -w '%{http_code}\n' https://<hostname>/readyz # 200 (a file, not /dev/null: see "When a step fails")
|
|
403
447
|
curl -sS https://<hostname>/api/v1/me | jq .signIn # the providers set, asked as nobody
|
|
404
|
-
cf "/accounts
|
|
405
|
-
aws ecs describe-services --cluster console --services api --query 'services[0].[runningCount,deployments[0].rolloutState]'
|
|
448
|
+
cf "/accounts/$CF_ACCOUNT/cfd_tunnel/$TUNNEL/connections" | jq '.result | length' # 1 or more
|
|
449
|
+
aws ecs describe-services --cluster console --services api --query 'services[0].[runningCount,deployments[0].rolloutState]' # [<the size's api count>, "COMPLETED"]
|
|
406
450
|
aws logs tail /console --since 15m --format short | grep -E 'migrations applied|api listening|files go to the bucket'
|
|
407
451
|
```
|
|
408
452
|
|
|
453
|
+
The three log lines are the console's stable start lines (`deploy/README.md` "Process contract"); `files go to the bucket` carries `"credentials":"platform"`.
|
|
454
|
+
|
|
409
455
|
Then the budget alert, at one and a half times the estimate, to the address of the plan, which emails when the month's bill passes it or is forecast to:
|
|
410
456
|
|
|
411
457
|
```sh
|
|
@@ -423,7 +469,7 @@ console login --url https://<hostname> --agent deploying-a-console # relay t
|
|
|
423
469
|
console use <key>
|
|
424
470
|
printf '# Deployment check\n\nHanded in by the deploying agent on <date> to prove the bucket.\n' > scratch/deployment-check.md
|
|
425
471
|
NUMBER=$(console task new "Deployment check" --body "What this run deployed, in a few lines: the size, the address, what is owed." --json | jq -r .number)
|
|
426
|
-
RUN_ID=$(console take "$NUMBER" --json | jq -r .id)
|
|
472
|
+
RUN_ID=$(console take "$NUMBER" --json | jq -r .run.id) # take answers the run and the task
|
|
427
473
|
console hand-in scratch/deployment-check.md --label "the check"
|
|
428
474
|
HASH=$(console run "$RUN_ID" --json | jq -r '.artifacts[0].file.sha256')
|
|
429
475
|
aws s3api head-object --bucket $BUCKET --key "$HASH" # the object, with its size
|
|
@@ -439,30 +485,31 @@ The hash `console run` shows is the object's key in the bucket (no prefix is set
|
|
|
439
485
|
```markdown
|
|
440
486
|
# The console's deployment
|
|
441
487
|
|
|
442
|
-
Made on <date> with the skill `deploying-a-console` of the SkillCDN Console (skillcdn.ai/gh/skillcdn/console), from
|
|
488
|
+
Made on <date> with the skill `deploying-a-console` of the SkillCDN Console (skillcdn.ai/gh/skillcdn/console), from release `@skillcdn/console@<version>` of `skillcdn/console` (commit `<CONSOLE_REF>`).
|
|
443
489
|
|
|
444
490
|
## What exists
|
|
445
491
|
|
|
446
492
|
| What | Where |
|
|
447
493
|
|---|---|
|
|
448
494
|
| The console | https://<hostname>, through Cloudflare (zone <domain>, tunnel `console`, id `<tunnel id>`) |
|
|
495
|
+
| Cloudflare rules | The rate limiting rule `console: sign-in and connect, per client` (ruleset `<id>`, rule `<id>`); always HTTPS as <the zone's setting | the redirect rule `console: https only` (ruleset `<id>`, rule `<id>`) | the zone's own setting, on already> |
|
|
449
496
|
| AWS account and region | <account id>, <region> |
|
|
450
497
|
| Containers | ECS cluster `console`; services `api` (<n> tasks of <cpu>/<memory>)<, `worker`>; task families `console-api`, `console-migrate`<, `console-worker`> |
|
|
451
498
|
| Database | RDS PostgreSQL <version>, `console`, <class>, <storage>, <one zone | two zones>, backups 7 days, deletion protection on |
|
|
452
|
-
| Files | S3 bucket `console-files-<account>`, read and written by the
|
|
499
|
+
| Files | S3 bucket `console-files-<account>`, read and written by the task's role `console-task`; no key exists |
|
|
453
500
|
| Secrets | SSM Parameter Store, `/console/*`: <the names> |
|
|
454
501
|
| Logs | CloudWatch Logs `/console`, kept 30 days |
|
|
455
|
-
| Images | ECR `console
|
|
456
|
-
| Roles | `console-task-execution`, `console-task
|
|
502
|
+
| Images | ECR `console`: one tag per deployment, and the connector's image under `cloudflared-<version>` |
|
|
503
|
+
| Roles | `console-task-execution`, `console-task` (the bucket), `console-github-deploy` (assumed by this repository's `main`) |
|
|
457
504
|
| Size and cost | `<size>`; about <estimate> a month at <date>'s prices; a budget alert at <amount> emails <address> |
|
|
458
505
|
|
|
459
506
|
## How it is run
|
|
460
507
|
|
|
461
508
|
- **A deployment** is a push to `main` that touches `deploy/` or `console-ui/`: the workflow builds the image, runs the migrations, and rolls the services. `gh run list` shows the runs.
|
|
462
|
-
- **A new version of the console:** set `CONSOLE_REF` in `deploy/console.env` to the commit, push.
|
|
509
|
+
- **A new version of the console:** set `CONSOLE_REF` in `deploy/console.env` to the commit of the newest release tag: the last line of `git ls-remote --tags https://github.com/skillcdn/console 'refs/tags/@skillcdn/console@*' | sort -t@ -k3 -V` is the highest version, with the commit in its first column; move `console-ui/` to the same version of the package when there is one; push. **A new connector:** `CLOUDFLARED_VERSION` there, push.
|
|
463
510
|
- **A person added or removed:** `MEMBERS` and `ADMINS` in `deploy/api.task.json`, push. Administrators also change roles on the People page.
|
|
464
|
-
- **The next size:** the numbers in `deploy/api.task.json` (and `deploy/worker.task.json` at `large`), `API_COUNT` and `WORKER_COUNT` in `deploy/console.env`, push; the database: `aws rds modify-db-instance --db-instance-identifier console --db-instance-class <class> --apply-immediately` (a few minutes of unavailability).
|
|
465
|
-
- **A secret rotated:** `aws ssm put-parameter --
|
|
511
|
+
- **The next size:** the numbers in `deploy/api.task.json` (and `deploy/worker.task.json` at `large`), `API_COUNT` and `WORKER_COUNT` in `deploy/console.env`, push; to `large`, also `deploy/worker.task.json` written as the skill's resources.md gives it and `WORKER_IN_PROCESS` dropped from the api task; the database: `aws rds modify-db-instance --db-instance-identifier console --db-instance-class <class> --apply-immediately` (a few minutes of unavailability).
|
|
512
|
+
- **A secret rotated:** the new value into a secrets file of the same shape with the skill's script (`set`), then `node <the script> store <file> /console`, which overwrites the parameter in its tier (`$(npm root -g)/@skillcdn/console/skills/deploying-a-console/scripts/store-secrets.mjs`; `DATABASE_CA` is in the advanced tier, which an `aws ssm put-parameter --overwrite` by hand must name with `--tier Advanced`); then `aws ecs update-service --cluster console --service api --force-new-deployment`.
|
|
466
513
|
- **The organization's own console:** its source in `console-ui/` (building-a-console makes it); a push builds it into the image.
|
|
467
514
|
- **Paused:** `aws ecs update-service --cluster console --service api --desired-count 0` (and `worker`), `aws rds stop-db-instance --db-instance-identifier console` (seven days at most); the reverse resumes.
|
|
468
515
|
- **Removed:** the skill's "Removing", resource by resource, confirmed.
|
|
@@ -476,11 +523,11 @@ Made on <date> with the skill `deploying-a-console` of the SkillCDN Console (ski
|
|
|
476
523
|
`/readyz` 200 at the address; <who> signed in as administrator; project `<key>` made; the agent connected with a code; the deployment check handed in and found in the bucket under its hash; workflow run <id> green.
|
|
477
524
|
```
|
|
478
525
|
|
|
479
|
-
A change to `DEPLOY.md` or `README.md` alone does not run the workflow (its `paths`), so the record is pushed without a deployment.
|
|
526
|
+
A change to `DEPLOY.md` or `README.md` alone does not run the workflow (its `paths`), so the record is pushed without a deployment: `git add DEPLOY.md README.md && git commit -m "The record" && git push`, never `git add -A` here, after `git status --short` shows nothing else of the folder as untracked (`scratch/`, the files and `.skillcdn-console.json` are ignored).
|
|
480
527
|
|
|
481
528
|
## Removing
|
|
482
529
|
|
|
483
|
-
Only on request, each step confirmed, in this order, after a last snapshot of the database: the services (`aws ecs update-service --desired-count 0`, then `delete-service`), the cluster; the hostname (`DELETE /zones/<zone>/dns_records/<id>`) and the tunnel (`DELETE /accounts/<account>/cfd_tunnel/<id>`, after `.../connections` is empty); the database (`modify-db-instance --no-deletion-protection`, then `delete-db-instance --final-db-snapshot-identifier console-final-<date>`); the bucket, emptied first (`aws s3 rm s3://<bucket> --recursive`, then `delete-bucket`); the parameters; the
|
|
530
|
+
Only on request, each step confirmed, in this order, after a last snapshot of the database: the services (`aws ecs update-service --desired-count 0`, then `delete-service`), the cluster; the hostname (`DELETE /zones/<zone>/dns_records/<id>`), the rules (`DELETE /zones/<zone>/rulesets/<ruleset>/rules/<rule>`, the two ids of the record; a zone-wide always-HTTPS the run turned on is left as it is, since the zone may have come to rely on it) and the tunnel (`DELETE /accounts/<account>/cfd_tunnel/<id>`, after `.../connections` is empty); the database (`modify-db-instance --no-deletion-protection`, then `delete-db-instance --final-db-snapshot-identifier console-final-<date>`); the bucket, emptied first (`aws s3 rm s3://<bucket> --recursive`, then `delete-bucket`); the parameters; the roles' policies and the roles; the log group; the image repository (`--force`); the security groups; the budget. The deployment repository stays, with the record of what was removed and the snapshot's name.
|
|
484
531
|
|
|
485
532
|
## When a step fails
|
|
486
533
|
|
|
@@ -488,6 +535,8 @@ Only on request, each step confirmed, in this order, after a last snapshot of th
|
|
|
488
535
|
|---|---|---|
|
|
489
536
|
| `AccessDenied` or `UnauthorizedOperation` naming an action | The deployer's policy lacks it | Add the action to the policy (access.md) and run the step again |
|
|
490
537
|
| A log group or a parameter gets a name like `C:/Program Files/Git/console` | Git Bash rewrote the `/console` argument | `export MSYS_NO_PATHCONV=1`, delete the misnamed resource, run the step again |
|
|
538
|
+
| With that set, `curl` says it cannot open `/dev/null`, or a program cannot find `/tmp/...` | Git Bash now passes those as written, and a native program on Windows knows no such paths | Give the program a file under `scratch/` instead (`-o scratch/out.txt`); the shell's own `> /dev/null` still works |
|
|
539
|
+
| `POST .../cfd_tunnel` answers `403` with code `10000` while listing tunnels works | The token has Read where Edit is needed: Read lists what Edit makes | Edit the token's permission (access.md's table, "Cloudflare One Connector: cloudflared"), run the step again; the same for a ruleset's `POST` under Zone WAF or Dynamic URL Redirects |
|
|
491
540
|
| `aws rds create-db-instance` refuses the engine version | The region has no PostgreSQL 18 yet | `describe-db-engine-versions --engine postgres` and take the newest major it lists |
|
|
492
541
|
| `create-bucket` says the name is taken | Bucket names are global | Add a short suffix and update the task definition and the files policy |
|
|
493
542
|
| `POST /zones` is refused with a permission error | The token lacks Zone Edit | Add the row (access.md) and run the step again |
|
|
@@ -497,20 +546,28 @@ Only on request, each step confirmed, in this order, after a last snapshot of th
|
|
|
497
546
|
| A task stops before it is healthy; the log says `api listening` and then nothing | The health check fails: wrong port or `/readyz` not `200` (database unreachable, schema behind) | `aws logs tail /console --since 10m`; `/readyz` says which of the three |
|
|
498
547
|
| The service never stabilizes and rolls back | The circuit breaker tripped on the above | Fix the cause; `--force-new-deployment` |
|
|
499
548
|
| The tunnel shows no connector while `api` is healthy | The token is wrong, or the task has no public IP for its egress | Check `TUNNEL_TOKEN` and `assignPublicIp=ENABLED` in the network configuration |
|
|
549
|
+
| The connector's log warns at start about ICMP or the ping group, and that it `failed to sufficiently increase receive buffer size` | Harmless on Fargate: no raw sockets for pinging through the tunnel, and a small UDP buffer; HTTP through the tunnel needs neither | Nothing; the connector is up when its log says `Registered tunnel connection` and the tunnel shows a connector |
|
|
550
|
+
| `files go to the bucket` is missing from the log, and the first file answers `503` | The task's role cannot reach the bucket: no `taskRoleArn`, no files policy on `console-task`, or `S3_CREDENTIALS` not `platform` | Check the three; the log's warning at start says what the bucket or the credentials answered |
|
|
500
551
|
| `https://<hostname>` answers 530 or 502 from Cloudflare | No connector yet, or the hostname's ingress rule points elsewhere | Wait for the service; check the tunnel's configuration and the DNS record |
|
|
501
552
|
| `https://<hostname>` does not resolve | The zone is not active yet | The nameservers at the registrar; `cf "/zones?name=<domain>"` says when |
|
|
502
553
|
| The sign-in button is missing | The provider's id or secret is not in the task definition | Both halves or neither; check `environment` and `secrets` |
|
|
503
554
|
| The sign-in is refused after the provider | The login is not in `MEMBERS` (or the domain not in `GOOGLE_WORKSPACE_DOMAIN`) | Add it, push |
|
|
504
|
-
| The
|
|
555
|
+
| The mirror step cannot pull `cloudflare/cloudflared` | Docker Hub's anonymous pull limit on the runner | Run the workflow again (`gh run rerun <id>`); the pull happens once per connector version, and the tasks pull from the repository afterwards |
|
|
505
556
|
| `gh run watch` shows the build failing on `npm ci` in `console-ui` | The UI's lockfile is out of step | Fix in `console-ui/` (building-a-console), push |
|
|
506
557
|
| A 524 at Cloudflare on a long read | A request held longer than the proxy's 100 seconds | The console holds a decision's read for 50 at most; look for a slow database |
|
|
507
558
|
|
|
559
|
+
## As infrastructure code
|
|
560
|
+
|
|
561
|
+
An organization that writes this shape as infrastructure code (Terraform, for one) instead of running the commands keeps the same resources, names and contract; the first such rendering found these worth knowing (2026-10). A tunnel resource with its configuration at Cloudflare (`config_src = "cloudflare"`) and no secret of its own keeps the tunnel's secret out of the state, and the connector token is then read by a script straight into the secret store rather than through a data source, which would land it in the state. The database's password and the parameters go in as write-only arguments where the provider has them, for the same reason. Where the secret store is Secrets Manager rather than the parameter store, two halves of a key pair (the bucket on R2) fit one JSON secret, which a task definition reads by key (`arn:...:secret:<name>:S3_ACCESS_KEY_ID::`). The tasks' role, the connector beside `api`, the read-only root filesystem and the rollout's state apply as written.
|
|
562
|
+
|
|
508
563
|
## Facts that move
|
|
509
564
|
|
|
510
565
|
- AWS App Runner stopped taking new customers on 2026-04-30 and is in maintenance; ECS Express Mode (2025-11) is AWS's successor and provisions a load balancer (checked 2026-10).
|
|
566
|
+
- ECS: `aws ecs wait services-stable` returns once the running count matches the desired one, before the tasks are healthy; a deployment's `rolloutState` is `COMPLETED` once they are, or `FAILED` when the circuit breaker rolled it back (checked 2026-10).
|
|
567
|
+
- The connector reaches Cloudflare on `7844` (QUIC over UDP, falling back to TCP) and `443`, and reads its token from `TUNNEL_TOKEN`; its image is `cloudflare/cloudflared` on Docker Hub, tagged by release (checked 2026-10).
|
|
511
568
|
- Fargate: `stopTimeout` from 2 to 120 seconds, 30 when unset; containers stop in the reverse order of their start dependencies (checked 2026-10).
|
|
512
569
|
- SSM Parameter Store: 4 KB per standard parameter, 8 KB per advanced one at about $0.05 a month; the RDS regional certificate bundle is about 4.5 KB with three authorities (checked 2026-10).
|
|
513
570
|
- RDS for PostgreSQL has had major version 18 since 2025-11 (checked 2026-10).
|
|
514
|
-
- Cloudflare: a proxied request's idle limit is 100 seconds on every plan but Enterprise; the Free plan allows one rate limiting rule with a ten-second window; a tunnel costs nothing
|
|
571
|
+
- Cloudflare: a proxied request's idle limit is 100 seconds on every plan but Enterprise; the Free plan allows one rate limiting rule with a ten-second window; a tunnel costs nothing; a remotely managed tunnel's routing is the `configurations` document, whose last rule has no hostname; creating a zone needs Zone Edit on the token; the tunnel's permission is listed as "Cloudflare One Connector: cloudflared" in the dashboard and reported as "Cloudflare Tunnel Write" by the API; a redirect rule lives in the phase `http_request_dynamic_redirect` under "Dynamic URL Redirects" (checked 2026-10).
|
|
515
572
|
- A new AWS account made after 2025-07-15 starts with $100 of credits and a choice between a free plan that ends after six months and a paid one (checked 2026-10).
|
|
516
|
-
- Git Bash on Windows rewrites an argument that starts with `/` into a Windows path for a native program unless `MSYS_NO_PATHCONV=1` is set (checked 2026-10).
|
|
573
|
+
- Git Bash on Windows rewrites an argument that starts with `/` into a Windows path for a native program unless `MSYS_NO_PATHCONV=1` is set; with it set, `/dev/null` and `/tmp/...` as arguments reach the program as written, which knows no such paths, while the shell's own redirections keep working (checked 2026-10).
|
|
@@ -78,7 +78,7 @@ Part of the plan, in plain words, one line per item, the chosen size first:
|
|
|
78
78
|
## Options the plan offers
|
|
79
79
|
|
|
80
80
|
- **The database as serverless capacity.** Aurora Serverless v2 for PostgreSQL scales its capacity with load, down to nothing when idle (0 ACU with automatic pause, since 2024-11). The console holds a listening connection open and sweeps every fifteen minutes, so it never idles and the database never pauses: the floor is 0.5 ACU, about $44 a month at $0.12 per ACU-hour (read 2026-10), more than the `small` and `medium` instances. Offer it when the organization wants capacity that follows load without resizing, and say the floor.
|
|
81
|
-
- **The bucket on Cloudflare R2** instead of S3: the same S3 API,
|
|
81
|
+
- **The bucket on Cloudflare R2** instead of S3: the same S3 API, `S3_ENDPOINT=https://<account id>.r2.cloudflarestorage.com`, `S3_REGION=auto`, path style; no egress charges. Its keys are made in the Cloudflare dashboard (R2, Manage API tokens, Object Read & Write on the one bucket), saved in the secrets file as `S3_ACCESS_KEY_ID` and `S3_SECRET_ACCESS_KEY`, stored as parameters and carried by the `api` task as secrets, with no `S3_CREDENTIALS` line: the task's role cannot reach R2. Offer it when the organization keeps its files at Cloudflare already; S3 stays the default because the task's own role reaches it with no key to keep, and the budget and the audit are then in one account.
|
|
82
82
|
- **A load balancer instead of the tunnel.** ECS Express Mode creates an Application Load Balancer with HTTPS on an AWS domain in one command, at about $16 to $23 a month for the balancer (read 2026-10); the custom domain is a manual step on the balancer, and Cloudflare then proxies to it. Offer it only when the organization does not want its traffic through the tunnel; the tunnel is the default because it costs nothing, exposes nothing, and brings the client's address to the console cleanly.
|
|
83
83
|
- **ARM tasks** when the organization has ARM runners for its builds (building for ARM on an x86 runner takes many times longer): a fifth off the task line.
|
|
84
84
|
|
|
@@ -5,7 +5,6 @@
|
|
|
5
5
|
// node store-secrets.mjs set <file> <NAME> the value is standard input
|
|
6
6
|
// node store-secrets.mjs derive <file> <NAME> <template> {{OTHER}} in the template is replaced
|
|
7
7
|
// node store-secrets.mjs render <file> <in> <out> a JSON file with {{NAME}} placeholders filled
|
|
8
|
-
// node store-secrets.mjs key <file> <iam user> creates an access key for the user and stores both halves
|
|
9
8
|
// node store-secrets.mjs store <file> <prefix> every value as a SecureString parameter <prefix>/<NAME>
|
|
10
9
|
import { execFileSync } from "node:child_process";
|
|
11
10
|
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
@@ -15,7 +14,7 @@ import process from "node:process";
|
|
|
15
14
|
|
|
16
15
|
const [mode, file, ...rest] = process.argv.slice(2);
|
|
17
16
|
const usage = () => {
|
|
18
|
-
process.stderr.write("usage: node store-secrets.mjs set|derive|render|
|
|
17
|
+
process.stderr.write("usage: node store-secrets.mjs set|derive|render|store <file> ...\n");
|
|
19
18
|
process.exit(2);
|
|
20
19
|
};
|
|
21
20
|
if (mode === undefined || file === undefined) {
|
|
@@ -73,19 +72,6 @@ switch (mode) {
|
|
|
73
72
|
writeFileSync(output, fill(read(), readFileSync(input, "utf8")), { mode: 0o600 });
|
|
74
73
|
break;
|
|
75
74
|
}
|
|
76
|
-
case "key": {
|
|
77
|
-
const [user] = rest;
|
|
78
|
-
if (user === undefined) {
|
|
79
|
-
usage();
|
|
80
|
-
}
|
|
81
|
-
const answer = JSON.parse(aws(["iam", "create-access-key"], { UserName: user }));
|
|
82
|
-
write({
|
|
83
|
-
...read(),
|
|
84
|
-
S3_ACCESS_KEY_ID: answer.AccessKey.AccessKeyId,
|
|
85
|
-
S3_SECRET_ACCESS_KEY: answer.AccessKey.SecretAccessKey,
|
|
86
|
-
});
|
|
87
|
-
break;
|
|
88
|
-
}
|
|
89
75
|
case "store": {
|
|
90
76
|
const [prefix] = rest;
|
|
91
77
|
if (prefix === undefined) {
|