@gambi97/keel-cli 0.1.1
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/LICENSE +21 -0
- package/README.md +401 -0
- package/dist/bootstrap/github.js +190 -0
- package/dist/bootstrap/infisical.js +95 -0
- package/dist/bootstrap/scaleway.js +65 -0
- package/dist/config.js +153 -0
- package/dist/generate.js +104 -0
- package/dist/index.js +294 -0
- package/dist/prompts.js +109 -0
- package/dist/state.js +29 -0
- package/dist/ui.js +76 -0
- package/package.json +61 -0
- package/templates/.github/scripts/sync-database-url.sh +49 -0
- package/templates/.github/workflows/terraform-apply.yml +103 -0
- package/templates/.github/workflows/terraform-drift.yml +91 -0
- package/templates/.github/workflows/terraform-plan.yml +63 -0
- package/templates/LICENSE +21 -0
- package/templates/README.md +164 -0
- package/templates/_gitignore +28 -0
- package/templates/backend.hcl.example +9 -0
- package/templates/backend.tf +19 -0
- package/templates/main.tf +28 -0
- package/templates/modules/app_stack/main.tf +70 -0
- package/templates/modules/app_stack/outputs.tf +23 -0
- package/templates/modules/app_stack/variables.tf +55 -0
- package/templates/modules/app_stack/versions.tf +7 -0
- package/templates/outputs.tf +20 -0
- package/templates/prod.tfvars +7 -0
- package/templates/providers.tf +16 -0
- package/templates/staging.tfvars +7 -0
- package/templates/variables.tf +96 -0
- package/templates/versions.tf +15 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Matteo Gambera
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="assets/logo.png" alt="keel" width="520">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<p align="center">
|
|
6
|
+
One command from zero to a production-shaped serverless infrastructure on Scaleway<br>(Terraform + GitHub Actions + Infisical).
|
|
7
|
+
</p>
|
|
8
|
+
|
|
9
|
+
<p align="center">
|
|
10
|
+
<a href="https://github.com/Gambi97/keel/actions/workflows/ci.yml"><img src="https://github.com/Gambi97/keel/actions/workflows/ci.yml/badge.svg" alt="CI status"></a>
|
|
11
|
+
<img src="https://img.shields.io/badge/node-%3E%3D18.17-339933?logo=node.js&logoColor=white" alt="node compatibility">
|
|
12
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue" alt="MIT license"></a>
|
|
13
|
+
</p>
|
|
14
|
+
|
|
15
|
+
<p align="center">
|
|
16
|
+
<a href="#quickstart">Quickstart</a> ·
|
|
17
|
+
<a href="#why-keel">Why keel</a> ·
|
|
18
|
+
<a href="#what-gets-created-and-when">What gets created</a> ·
|
|
19
|
+
<a href="#what-it-costs-to-start">Costs</a> ·
|
|
20
|
+
<a href="#faq">FAQ</a>
|
|
21
|
+
</p>
|
|
22
|
+
|
|
23
|
+
A ship's keel is the first beam laid down, the backbone everything else is
|
|
24
|
+
built onto. `keel` lays that foundation for whatever you are building: a
|
|
25
|
+
complete serverless infrastructure on [Scaleway](https://www.scaleway.com),
|
|
26
|
+
provisioned by Terraform, deployed by GitHub Actions, with secrets in
|
|
27
|
+
[Infisical](https://infisical.com). It does not generate an app: you bring a
|
|
28
|
+
Docker image, keel gives it a place to run. You answer a few questions, push
|
|
29
|
+
to `main`, and the infrastructure is live.
|
|
30
|
+
|
|
31
|
+
- **Near-free to start.** Compute and database scale to zero; an idle project
|
|
32
|
+
costs cents per month, with no expiring trial.
|
|
33
|
+
- **Scales with your product.** Staging and prod from day one; growing is a
|
|
34
|
+
one-line tfvars change reviewed in a PR.
|
|
35
|
+
- **Nothing to install.** No Terraform, no cloud CLI. Node and git, done.
|
|
36
|
+
- **No secrets in the repo, ever.** Encrypted CI secrets and a dedicated
|
|
37
|
+
secret manager, wired for you.
|
|
38
|
+
- **No lock-in of your code.** Your app is a plain Docker image on a port.
|
|
39
|
+
|
|
40
|
+
## Quickstart
|
|
41
|
+
|
|
42
|
+
You need Node >= 18.17, git, and credentials for Scaleway, Infisical and
|
|
43
|
+
GitHub (see [Prerequisites](#prerequisites) for how to get each one).
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
npx @gambi97/keel-cli
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The CLI asks for a project name, region, repository name and visibility, picks
|
|
50
|
+
up `SCW_*` / `INFISICAL_*` / `GITHUB_TOKEN` from your environment as defaults,
|
|
51
|
+
then shows a **complete summary of what it will create and where**. Nothing is
|
|
52
|
+
touched before you confirm. When it finishes, push to `main` (or merge the
|
|
53
|
+
first PR) and the pipeline provisions the infrastructure.
|
|
54
|
+
|
|
55
|
+
Non-interactive and dry-run:
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
# scripts / CI: every question has a flag
|
|
59
|
+
npx @gambi97/keel-cli --yes --name my-app --region fr-par --private
|
|
60
|
+
|
|
61
|
+
# preview: generates the repo locally, touches no account
|
|
62
|
+
npx @gambi97/keel-cli --dry-run --yes --name my-app
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
See the full [CLI reference](#cli-reference) for all flags, or pass
|
|
66
|
+
`--config answers.json`.
|
|
67
|
+
|
|
68
|
+
## Why keel
|
|
69
|
+
|
|
70
|
+
Getting anything real online means gluing together a cloud account, a
|
|
71
|
+
container runtime, a database, a state store, a CI/CD pipeline and a secret
|
|
72
|
+
manager, then threading credentials through all of them without leaking
|
|
73
|
+
anything. It is a day of undifferentiated setup before the first line of
|
|
74
|
+
product.
|
|
75
|
+
|
|
76
|
+
`keel` collapses that into one command, guided by three opinions:
|
|
77
|
+
|
|
78
|
+
**1. Start near zero, grow without re-architecting.**
|
|
79
|
+
Both compute (Serverless Containers) and database (Serverless SQL) bill per
|
|
80
|
+
use and scale to zero. The same repo that costs cents while you validate the
|
|
81
|
+
idea carries real traffic later by bumping two numbers in a tfvars file.
|
|
82
|
+
See [the cost breakdown](#what-it-costs-to-start).
|
|
83
|
+
|
|
84
|
+
**2. The repository is the single source of truth, and it holds zero secrets.**
|
|
85
|
+
All infrastructure is Terraform reviewed in PRs; nothing is click-configured.
|
|
86
|
+
Terraform runs in CI, never on a laptop. Credentials live in GitHub's
|
|
87
|
+
encrypted store (CI) and Infisical (application), so rotating a secret never
|
|
88
|
+
requires a commit.
|
|
89
|
+
|
|
90
|
+
**3. Infrastructure only, no framework.**
|
|
91
|
+
keel generates no application skeleton. Your app is any Docker image,
|
|
92
|
+
listening on a port, reading its config from environment variables. Nothing
|
|
93
|
+
to rewrite if you outgrow the stack.
|
|
94
|
+
|
|
95
|
+
### Why Scaleway, and not AWS or the others
|
|
96
|
+
|
|
97
|
+
AWS can absolutely do this, but the equivalent setup on AWS means assembling
|
|
98
|
+
Lambda + API Gateway + Aurora + ECR + S3 + a thick layer of IAM, each with its
|
|
99
|
+
own knobs. keel optimizes for a different thing: **the shortest path from
|
|
100
|
+
nothing to running, cheap, scalable infrastructure.**
|
|
101
|
+
|
|
102
|
+
- **Truly scale-to-zero, on both tiers.** Scaleway Serverless Containers
|
|
103
|
+
_and_ Serverless SQL drop to zero when idle. On AWS, a serverless database
|
|
104
|
+
typically keeps minimum billable capacity running; for a project that is
|
|
105
|
+
quiet most of the time, that difference is the whole point.
|
|
106
|
+
- **No free tier clock.** Pricing starts near zero and stays per-second;
|
|
107
|
+
nothing expires after 12 months.
|
|
108
|
+
- **A handful of resources instead of a sprawl.** Less to assemble, less IAM
|
|
109
|
+
surface to misconfigure.
|
|
110
|
+
- **Standard containers.** A plain Docker image, portable by default.
|
|
111
|
+
- **EU-based**, with straightforward pricing and data residency.
|
|
112
|
+
|
|
113
|
+
The trade-off is honest: Scaleway has a smaller catalog than the
|
|
114
|
+
hyperscalers. If you need a specific managed AWS service, this is not for
|
|
115
|
+
you. If you want a serverless product online today for near nothing, it fits.
|
|
116
|
+
|
|
117
|
+
### Why Infisical for secrets
|
|
118
|
+
|
|
119
|
+
Secrets are the easiest thing to get wrong: a `DATABASE_URL` committed "just
|
|
120
|
+
for now", credentials copy-pasted across consoles until nobody knows the
|
|
121
|
+
source of truth.
|
|
122
|
+
|
|
123
|
+
- **One home for application secrets**, separate from code and CI
|
|
124
|
+
credentials, with clean staging/prod separation.
|
|
125
|
+
- **Rotate without a deploy.** Change the value, the next apply picks it up.
|
|
126
|
+
- **Read from both sides.** Terraform reads at plan/apply, the container
|
|
127
|
+
reads at runtime, same place.
|
|
128
|
+
- **Open source and self-hostable.** Point keel at your own instance.
|
|
129
|
+
|
|
130
|
+
CI credentials (the Scaleway and Infisical keys themselves) stay in GitHub
|
|
131
|
+
Actions encrypted secrets, because that is what CI needs to boot.
|
|
132
|
+
|
|
133
|
+
## How it works
|
|
134
|
+
|
|
135
|
+
```mermaid
|
|
136
|
+
flowchart TD
|
|
137
|
+
You([You: npx @gambi97/keel-cli]) -->|APIs, after you confirm| CLI{{CLI bootstrap}}
|
|
138
|
+
CLI -->|creates state bucket| SCW[(Scaleway<br/>Object Storage)]
|
|
139
|
+
CLI -->|creates repo, secrets,<br/>variables, branch rules| GH[GitHub repo]
|
|
140
|
+
CLI -->|creates project, envs,<br/>placeholder secrets| INF[Infisical]
|
|
141
|
+
GH -->|push to main| ACT[GitHub Actions]
|
|
142
|
+
ACT -->|terraform apply| PROD[Scaleway:<br/>Container + SQL + Registry]
|
|
143
|
+
INF -.->|app secrets at runtime| PROD
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
The CLI does the **bootstrap**, in seconds, on your machine. The first
|
|
147
|
+
`terraform apply` runs **in CI** on the first push to `main`, so nobody needs
|
|
148
|
+
local tooling or production credentials.
|
|
149
|
+
|
|
150
|
+
## What gets created, and when
|
|
151
|
+
|
|
152
|
+
**Phase A: bootstrap (the CLI, via APIs, after your confirmation)**
|
|
153
|
+
|
|
154
|
+
| Where | What |
|
|
155
|
+
| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
156
|
+
| Your machine | The generated repo: Terraform, workflows, README, initial git commit |
|
|
157
|
+
| Scaleway | One Object Storage bucket for Terraform state (versioned, with native state locking) |
|
|
158
|
+
| GitHub | Repository (public or private) pushed to `main`; encrypted Actions secrets; Actions variables; `staging` and `production` environments, the latter gated by manual approval; branch protection on `main` |
|
|
159
|
+
| Infisical | A project with `staging` and `prod` environments, seeded with `BASIC_AUTH_USER` / `BASIC_AUTH_PASSWORD` (staging, random password) and a `DATABASE_URL` placeholder per environment |
|
|
160
|
+
|
|
161
|
+
**Phase B: first deploy (Terraform in GitHub Actions, on push to `main`)**
|
|
162
|
+
|
|
163
|
+
| Scaleway resource | Notes |
|
|
164
|
+
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------- |
|
|
165
|
+
| Registry namespace | Private, one per environment |
|
|
166
|
+
| Container namespace + Serverless Container | The container appears once you set `container_image` in the tfvars; registry and database are created right away |
|
|
167
|
+
| Serverless SQL Database | One per environment; after each apply the pipeline writes a ready-to-use connection string to Infisical as `DATABASE_URL` |
|
|
168
|
+
| IAM application + API key | Dedicated credential that can only read/write the database (least privilege), embedded in `DATABASE_URL` |
|
|
169
|
+
|
|
170
|
+
No custom domain is configured: the app gets an auto-generated Scaleway URL.
|
|
171
|
+
Add one later with a single `scaleway_container_domain` resource.
|
|
172
|
+
|
|
173
|
+
## The generated repository
|
|
174
|
+
|
|
175
|
+
```
|
|
176
|
+
my-app/
|
|
177
|
+
├── README.md # operating manual for the repo
|
|
178
|
+
├── .github/workflows/
|
|
179
|
+
│ ├── terraform-plan.yml # PR: fmt + validate + plan (staging & prod)
|
|
180
|
+
│ ├── terraform-apply.yml # main: apply staging -> approval -> apply prod
|
|
181
|
+
│ └── terraform-drift.yml # weekly: read-only plan, opens an issue on drift
|
|
182
|
+
├── versions.tf · providers.tf · backend.tf
|
|
183
|
+
├── backend.hcl.example # state bucket coordinates (backend.hcl is git-ignored)
|
|
184
|
+
├── variables.tf · main.tf · outputs.tf
|
|
185
|
+
├── staging.tfvars · prod.tfvars # non-sensitive config only, committed
|
|
186
|
+
└── modules/app_stack/ # registry + container + database + db credential
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Environments are separated with **Terraform workspaces**: same code, two
|
|
190
|
+
independent states in one bucket, differences confined to the two `.tfvars`
|
|
191
|
+
files.
|
|
192
|
+
|
|
193
|
+
| Data | Lives in | Why |
|
|
194
|
+
| --------------------------------- | ------------------------ | -------------------------------------------------------------------- |
|
|
195
|
+
| Scaleway API keys | GitHub encrypted secrets | CI needs them to run Terraform |
|
|
196
|
+
| Infisical machine identity | GitHub encrypted secrets | Lets Terraform read app secrets at plan/apply |
|
|
197
|
+
| Basic Auth user/password | Infisical (staging) | App secret, injected into the container, rotatable |
|
|
198
|
+
| Database connection string | Infisical (both envs) | Complete, ready-to-use value synced by the pipeline after each apply |
|
|
199
|
+
| Bucket, region, Infisical project | GitHub variables | Non-sensitive wiring, editable in one place |
|
|
200
|
+
| Project name, scaling, image | Committed tfvars | Reviewable configuration, no secrets |
|
|
201
|
+
|
|
202
|
+
## After the bootstrap
|
|
203
|
+
|
|
204
|
+
The generated repository ships its own README: the full operating manual for
|
|
205
|
+
day-2 work (scaling, rotating secrets, custom domains, troubleshooting). The
|
|
206
|
+
short version of the first deploy:
|
|
207
|
+
|
|
208
|
+
1. **Push to `main`** (or merge a PR): the pipeline provisions registry and
|
|
209
|
+
databases. Approve the `production` gate when prompted.
|
|
210
|
+
2. **Build and push your app image** to the registry endpoint from the apply
|
|
211
|
+
output:
|
|
212
|
+
```sh
|
|
213
|
+
docker login rg.fr-par.scw.cloud/my-app-staging -u nologin --password-stdin <<< "$SCW_SECRET_KEY"
|
|
214
|
+
docker build -t rg.fr-par.scw.cloud/my-app-staging/app:latest .
|
|
215
|
+
docker push rg.fr-par.scw.cloud/my-app-staging/app:latest
|
|
216
|
+
```
|
|
217
|
+
3. **Set `container_image`** in the tfvars and open a PR: the next apply
|
|
218
|
+
creates the containers.
|
|
219
|
+
4. **Replace the placeholder secrets** in Infisical with real values. The app
|
|
220
|
+
reads `DATABASE_URL` and `BASIC_AUTH_*` from its environment; on staging
|
|
221
|
+
it also receives `BASIC_AUTH_ENABLED=true` and enforces it.
|
|
222
|
+
|
|
223
|
+
## Prerequisites
|
|
224
|
+
|
|
225
|
+
Three accounts and their credentials. Nothing is installed locally: the CLI
|
|
226
|
+
talks to each service through its API and **validates every credential before
|
|
227
|
+
creating anything**.
|
|
228
|
+
|
|
229
|
+
<details>
|
|
230
|
+
<summary><b>Scaleway</b> (the cloud provider)</summary>
|
|
231
|
+
<br>
|
|
232
|
+
|
|
233
|
+
1. Create an account and a project at [console.scaleway.com](https://console.scaleway.com).
|
|
234
|
+
2. Generate an API key (IAM > API keys > Generate): you need the **access
|
|
235
|
+
key** and the **secret key**.
|
|
236
|
+
3. Note your **project ID** and **organization ID** (Project Dashboard).
|
|
237
|
+
4. The key needs to create Object Storage buckets and manage Serverless
|
|
238
|
+
Containers, Serverless SQL and the Registry (`ObjectStorageFullAccess`,
|
|
239
|
+
`ContainersFullAccess`, `ServerlessSQLDatabaseFullAccess`,
|
|
240
|
+
`ContainerRegistryFullAccess`, plus `IAMManager` so Terraform can create
|
|
241
|
+
the app's dedicated least-privilege database credential).
|
|
242
|
+
|
|
243
|
+
</details>
|
|
244
|
+
|
|
245
|
+
<details>
|
|
246
|
+
<summary><b>Infisical</b> (the secret manager)</summary>
|
|
247
|
+
<br>
|
|
248
|
+
|
|
249
|
+
1. Create an account at [app.infisical.com](https://app.infisical.com) (or
|
|
250
|
+
use a self-hosted instance).
|
|
251
|
+
2. Create a **Machine Identity** with **Universal Auth**: you need its
|
|
252
|
+
**client ID** and **client secret**.
|
|
253
|
+
3. Give the identity permission to create and manage projects.
|
|
254
|
+
|
|
255
|
+
</details>
|
|
256
|
+
|
|
257
|
+
<details>
|
|
258
|
+
<summary><b>GitHub</b> (code hosting and CI)</summary>
|
|
259
|
+
<br>
|
|
260
|
+
|
|
261
|
+
1. Create a token at [github.com/settings/tokens](https://github.com/settings/tokens)
|
|
262
|
+
with the `repo` and `workflow` scopes (classic), or a fine-grained token
|
|
263
|
+
allowed to create repos and manage Actions secrets, variables,
|
|
264
|
+
environments and branch protection.
|
|
265
|
+
|
|
266
|
+
</details>
|
|
267
|
+
|
|
268
|
+
## Security model
|
|
269
|
+
|
|
270
|
+
- **No secret ever lands in the repository**: not in Terraform, not in
|
|
271
|
+
tfvars, not in workflows. `backend.hcl` and local state are git-ignored.
|
|
272
|
+
- The CLI never logs credentials and redacts them in the summary. The GitHub
|
|
273
|
+
token is handed to `git push` through an ephemeral askpass helper, so it
|
|
274
|
+
never appears in remote URLs, `.git/config` or the process list.
|
|
275
|
+
- Actions secrets are encrypted client-side (libsodium sealed box) before
|
|
276
|
+
upload.
|
|
277
|
+
- `main` is protected: force pushes and deletion blocked, PRs need a green
|
|
278
|
+
plan, production applies need manual approval.
|
|
279
|
+
- Terraform state lives in a private, versioned bucket with S3-native state
|
|
280
|
+
locking (`use_lockfile`), so concurrent applies cannot corrupt it.
|
|
281
|
+
- The app connects to its database with a **dedicated least-privilege IAM
|
|
282
|
+
credential** (read/write on that database, nothing else), not with your
|
|
283
|
+
main API key.
|
|
284
|
+
- A weekly drift-detection plan opens an issue when the real infrastructure
|
|
285
|
+
no longer matches the code.
|
|
286
|
+
|
|
287
|
+
## What it costs to start
|
|
288
|
+
|
|
289
|
+
keel is tuned to sit **near the free tier** while you have little or no
|
|
290
|
+
traffic. A rough monthly picture for a minimal setup, staging and prod
|
|
291
|
+
included (Scaleway `fr-par` list prices, excl. VAT):
|
|
292
|
+
|
|
293
|
+
| Component | Minimal setup | Monthly cost |
|
|
294
|
+
| --------------------------------- | -------------------------- | ------------------------------------------ |
|
|
295
|
+
| Serverless Containers (both envs) | scale-to-zero, low traffic | ~€0 (200k vCPU-s + 400k GB-s free / month) |
|
|
296
|
+
| Serverless SQL (both envs) | idle most of the time | ~€0.20 storage + a few cents of compute |
|
|
297
|
+
| Object Storage (Terraform state) | a few MB | ~€0 |
|
|
298
|
+
| Container Registry | 1-2 image versions | ~€0.05 (€0 if the registry is public) |
|
|
299
|
+
| **Total to start** | | **under ~€1 / month** |
|
|
300
|
+
|
|
301
|
+
Compute is billed per second, only while actually serving: an idle container
|
|
302
|
+
and a paused database drop to zero and you pay a few cents of storage. Cost
|
|
303
|
+
grows with real usage, not with the number of environments. When traffic
|
|
304
|
+
arrives, raise `min_scale` / `max_scale` in the tfvars and you move from
|
|
305
|
+
"near free" to paying for the capacity you actually use, with no
|
|
306
|
+
re-architecting.
|
|
307
|
+
|
|
308
|
+
## Failure recovery
|
|
309
|
+
|
|
310
|
+
Every bootstrap step checks whether its resource already exists, and progress
|
|
311
|
+
is recorded in `.keel.json` inside the project directory. If a run fails
|
|
312
|
+
halfway, fix the cause and **re-run the same command**: completed steps are
|
|
313
|
+
skipped, existing resources are reused, nothing is duplicated.
|
|
314
|
+
|
|
315
|
+
## FAQ
|
|
316
|
+
|
|
317
|
+
**Do I need Terraform, `scw` or `gh` installed?**
|
|
318
|
+
No. The CLI bootstraps via APIs; Terraform runs inside GitHub Actions.
|
|
319
|
+
|
|
320
|
+
**Can the repository be private?**
|
|
321
|
+
Yes. The CLI asks for the name and the visibility; default is public (the
|
|
322
|
+
infra holds no secrets), or choose private interactively or with `--private`.
|
|
323
|
+
|
|
324
|
+
**Why is the container not created on the first apply?**
|
|
325
|
+
A Serverless Container needs an image, and none exists yet. Registry and
|
|
326
|
+
database are created immediately; the container is gated on
|
|
327
|
+
`container_image`, so the first apply is green instead of failing.
|
|
328
|
+
|
|
329
|
+
**Why Basic Auth "at the app level"?**
|
|
330
|
+
Scaleway Serverless Containers have no built-in auth in front of public
|
|
331
|
+
endpoints. Credentials live in Infisical, the container gets
|
|
332
|
+
`BASIC_AUTH_ENABLED=true`, and a few lines of middleware enforce it.
|
|
333
|
+
|
|
334
|
+
**Can I add more environments?**
|
|
335
|
+
Yes: add a workspace, a `<env>.tfvars`, an Infisical environment, and mirror
|
|
336
|
+
one job in each workflow.
|
|
337
|
+
|
|
338
|
+
## CLI reference
|
|
339
|
+
|
|
340
|
+
```
|
|
341
|
+
--name <name> Project name (dns-safe: lowercase, digits, hyphens)
|
|
342
|
+
--dir <path> Target directory (default: ./<name>)
|
|
343
|
+
--region <region> fr-par | nl-ams | pl-waw (default: fr-par)
|
|
344
|
+
--scw-access-key <key> or env SCW_ACCESS_KEY
|
|
345
|
+
--scw-secret-key <key> or env SCW_SECRET_KEY
|
|
346
|
+
--scw-project-id <id> or env SCW_DEFAULT_PROJECT_ID
|
|
347
|
+
--scw-organization-id <id> or env SCW_DEFAULT_ORGANIZATION_ID
|
|
348
|
+
--infisical-host <url> or env INFISICAL_HOST (default: https://app.infisical.com)
|
|
349
|
+
--infisical-client-id <id> or env INFISICAL_CLIENT_ID
|
|
350
|
+
--infisical-client-secret <s> or env INFISICAL_CLIENT_SECRET
|
|
351
|
+
--infisical-project-name <n> Infisical project (default: project name)
|
|
352
|
+
--github-token <token> or env GITHUB_TOKEN / GH_TOKEN (scopes: repo, workflow)
|
|
353
|
+
--repo-name <name> GitHub repository name (default: project name)
|
|
354
|
+
--private / --public Repository visibility (default: public)
|
|
355
|
+
--no-basic-auth Disable Basic Auth on staging
|
|
356
|
+
--staging-min-scale <n> Default 0 --staging-max-scale <n> Default 1
|
|
357
|
+
--prod-min-scale <n> Default 0 --prod-max-scale <n> Default 2
|
|
358
|
+
--config <file.json> Load answers from a JSON file
|
|
359
|
+
--advanced Also ask scaling questions interactively
|
|
360
|
+
--yes Accept defaults, skip the confirmation prompt
|
|
361
|
+
--dry-run Generate locally, touch no account
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
A `--config` file uses the same nested shape as the flags; anything missing
|
|
365
|
+
is taken from the environment or asked interactively:
|
|
366
|
+
|
|
367
|
+
```json
|
|
368
|
+
{
|
|
369
|
+
"projectName": "my-app",
|
|
370
|
+
"region": "fr-par",
|
|
371
|
+
"github": { "repoPrivate": true },
|
|
372
|
+
"scaling": { "prodMaxScale": 4 }
|
|
373
|
+
}
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
## Development
|
|
377
|
+
|
|
378
|
+
```sh
|
|
379
|
+
npm install
|
|
380
|
+
npm run build # tsc -> dist/
|
|
381
|
+
npm test # vitest unit tests
|
|
382
|
+
npm run lint # eslint
|
|
383
|
+
npm run verify:templates # render templates + terraform fmt/validate (needs terraform)
|
|
384
|
+
node dist/index.js --dry-run --yes --name demo # end-to-end without accounts
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
### Releasing (maintainers)
|
|
388
|
+
|
|
389
|
+
Publishing runs in CI, never from a laptop:
|
|
390
|
+
|
|
391
|
+
```sh
|
|
392
|
+
npm version minor # bumps package.json, commits and tags vX.Y.Z
|
|
393
|
+
git push --follow-tags # the Release workflow publishes to npm with provenance
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
The workflow refuses a tag that does not match `package.json` and skips
|
|
397
|
+
versions that are already on the registry, so re-running it is always safe.
|
|
398
|
+
|
|
399
|
+
## License
|
|
400
|
+
|
|
401
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { Octokit } from '@octokit/rest';
|
|
7
|
+
// The ESM build of libsodium-wrappers is broken (missing libsodium.mjs), so
|
|
8
|
+
// load the CommonJS build explicitly.
|
|
9
|
+
const require = createRequire(import.meta.url);
|
|
10
|
+
const sodium = require('libsodium-wrappers');
|
|
11
|
+
export class GitHubError extends Error {
|
|
12
|
+
}
|
|
13
|
+
export async function createContext(answers) {
|
|
14
|
+
const octokit = new Octokit({ auth: answers.github.token });
|
|
15
|
+
let login;
|
|
16
|
+
let ownerId;
|
|
17
|
+
try {
|
|
18
|
+
const { data } = await octokit.users.getAuthenticated();
|
|
19
|
+
login = data.login;
|
|
20
|
+
ownerId = data.id;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
throw new GitHubError('GitHub rejected the token. It needs the "repo" and "workflow" scopes ' +
|
|
24
|
+
'(classic PAT) or equivalent fine-grained permissions.');
|
|
25
|
+
}
|
|
26
|
+
return {
|
|
27
|
+
octokit,
|
|
28
|
+
owner: login,
|
|
29
|
+
ownerId,
|
|
30
|
+
repo: answers.github.repoName,
|
|
31
|
+
repoPrivate: answers.github.repoPrivate,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/** Create the repository, or reuse it when it already exists. */
|
|
35
|
+
export async function ensureRepo(ctx) {
|
|
36
|
+
try {
|
|
37
|
+
const { data } = await ctx.octokit.repos.get({ owner: ctx.owner, repo: ctx.repo });
|
|
38
|
+
if (!data.permissions?.push) {
|
|
39
|
+
throw new GitHubError(`Repository ${ctx.owner}/${ctx.repo} exists but the token cannot push to it.`);
|
|
40
|
+
}
|
|
41
|
+
return { created: false, url: data.html_url };
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
if (error instanceof GitHubError)
|
|
45
|
+
throw error;
|
|
46
|
+
if (error.status !== 404)
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
const { data } = await ctx.octokit.repos.createForAuthenticatedUser({
|
|
50
|
+
name: ctx.repo,
|
|
51
|
+
private: ctx.repoPrivate,
|
|
52
|
+
description: 'Serverless infrastructure on Scaleway, generated by keel',
|
|
53
|
+
has_wiki: false,
|
|
54
|
+
has_projects: false,
|
|
55
|
+
auto_init: false,
|
|
56
|
+
});
|
|
57
|
+
return { created: true, url: data.html_url };
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Push the generated repo over HTTPS. The token is handed to git through a
|
|
61
|
+
* temporary GIT_ASKPASS helper reading an environment variable, so it never
|
|
62
|
+
* appears in the remote URL, in .git/config or in the process list.
|
|
63
|
+
*/
|
|
64
|
+
export function pushRepo(ctx, token, targetDir) {
|
|
65
|
+
const remoteUrl = `https://github.com/${ctx.owner}/${ctx.repo}.git`;
|
|
66
|
+
const askpassDir = mkdtempSync(join(tmpdir(), 'keel-askpass-'));
|
|
67
|
+
const askpass = join(askpassDir, 'askpass.sh');
|
|
68
|
+
writeFileSync(askpass, '#!/bin/sh\necho "$KEEL_GIT_TOKEN"\n');
|
|
69
|
+
chmodSync(askpass, 0o700);
|
|
70
|
+
try {
|
|
71
|
+
const git = (args) => spawnSync('git', args, {
|
|
72
|
+
cwd: targetDir,
|
|
73
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
74
|
+
env: {
|
|
75
|
+
...process.env,
|
|
76
|
+
GIT_ASKPASS: askpass,
|
|
77
|
+
KEEL_GIT_TOKEN: token,
|
|
78
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
git(['remote', 'remove', 'origin']);
|
|
82
|
+
const addRemote = git(['remote', 'add', 'origin', remoteUrl]);
|
|
83
|
+
if (addRemote.status !== 0) {
|
|
84
|
+
throw new GitHubError(`git remote add failed: ${addRemote.stderr.toString().trim()}`);
|
|
85
|
+
}
|
|
86
|
+
// The remote embeds the username only; the password comes from askpass.
|
|
87
|
+
const setUser = git(['config', 'credential.username', 'x-access-token']);
|
|
88
|
+
if (setUser.status !== 0) {
|
|
89
|
+
throw new GitHubError(`git config failed: ${setUser.stderr.toString().trim()}`);
|
|
90
|
+
}
|
|
91
|
+
const push = git(['push', '-u', 'origin', 'main']);
|
|
92
|
+
if (push.status !== 0) {
|
|
93
|
+
throw new GitHubError(`git push failed: ${push.stderr.toString().trim()}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
rmSync(askpassDir, { recursive: true, force: true });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/** Encrypt and upload every CI secret, then set the plain variables. */
|
|
101
|
+
export async function configureRepo(ctx, answers, infisicalProjectId) {
|
|
102
|
+
// The workflows map AWS_* (state backend) from the same SCW_* secrets, so
|
|
103
|
+
// each credential is stored once and rotated in one place.
|
|
104
|
+
await setSecrets(ctx, {
|
|
105
|
+
SCW_ACCESS_KEY: answers.scaleway.accessKey,
|
|
106
|
+
SCW_SECRET_KEY: answers.scaleway.secretKey,
|
|
107
|
+
SCW_DEFAULT_PROJECT_ID: answers.scaleway.projectId,
|
|
108
|
+
SCW_DEFAULT_ORGANIZATION_ID: answers.scaleway.organizationId,
|
|
109
|
+
INFISICAL_CLIENT_ID: answers.infisical.clientId,
|
|
110
|
+
INFISICAL_CLIENT_SECRET: answers.infisical.clientSecret,
|
|
111
|
+
});
|
|
112
|
+
await setVariables(ctx, {
|
|
113
|
+
TF_STATE_BUCKET: answers.stateBucket,
|
|
114
|
+
SCW_REGION: answers.region,
|
|
115
|
+
INFISICAL_PROJECT_ID: infisicalProjectId,
|
|
116
|
+
INFISICAL_HOST: answers.infisical.host,
|
|
117
|
+
});
|
|
118
|
+
await configureEnvironments(ctx);
|
|
119
|
+
await protectMainBranch(ctx);
|
|
120
|
+
}
|
|
121
|
+
async function setSecrets(ctx, secrets) {
|
|
122
|
+
await sodium.ready;
|
|
123
|
+
const { data: key } = await ctx.octokit.actions.getRepoPublicKey({
|
|
124
|
+
owner: ctx.owner,
|
|
125
|
+
repo: ctx.repo,
|
|
126
|
+
});
|
|
127
|
+
for (const [name, value] of Object.entries(secrets)) {
|
|
128
|
+
const sealed = sodium.crypto_box_seal(sodium.from_string(value), sodium.from_base64(key.key, sodium.base64_variants.ORIGINAL));
|
|
129
|
+
await ctx.octokit.actions.createOrUpdateRepoSecret({
|
|
130
|
+
owner: ctx.owner,
|
|
131
|
+
repo: ctx.repo,
|
|
132
|
+
secret_name: name,
|
|
133
|
+
encrypted_value: sodium.to_base64(sealed, sodium.base64_variants.ORIGINAL),
|
|
134
|
+
key_id: key.key_id,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
async function setVariables(ctx, variables) {
|
|
139
|
+
for (const [name, value] of Object.entries(variables)) {
|
|
140
|
+
try {
|
|
141
|
+
await ctx.octokit.actions.createRepoVariable({
|
|
142
|
+
owner: ctx.owner,
|
|
143
|
+
repo: ctx.repo,
|
|
144
|
+
name,
|
|
145
|
+
value,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
if (error.status !== 409)
|
|
150
|
+
throw error;
|
|
151
|
+
await ctx.octokit.actions.updateRepoVariable({
|
|
152
|
+
owner: ctx.owner,
|
|
153
|
+
repo: ctx.repo,
|
|
154
|
+
name,
|
|
155
|
+
value,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
/** staging deploys automatically; production requires a manual approval. */
|
|
161
|
+
async function configureEnvironments(ctx) {
|
|
162
|
+
await ctx.octokit.repos.createOrUpdateEnvironment({
|
|
163
|
+
owner: ctx.owner,
|
|
164
|
+
repo: ctx.repo,
|
|
165
|
+
environment_name: 'staging',
|
|
166
|
+
});
|
|
167
|
+
await ctx.octokit.repos.createOrUpdateEnvironment({
|
|
168
|
+
owner: ctx.owner,
|
|
169
|
+
repo: ctx.repo,
|
|
170
|
+
environment_name: 'production',
|
|
171
|
+
reviewers: [{ type: 'User', id: ctx.ownerId }],
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
/** Require a green plan on PRs and forbid force-pushes/deletion of main. */
|
|
175
|
+
async function protectMainBranch(ctx) {
|
|
176
|
+
await ctx.octokit.repos.updateBranchProtection({
|
|
177
|
+
owner: ctx.owner,
|
|
178
|
+
repo: ctx.repo,
|
|
179
|
+
branch: 'main',
|
|
180
|
+
required_status_checks: {
|
|
181
|
+
strict: false,
|
|
182
|
+
contexts: ['plan (staging)', 'plan (prod)'],
|
|
183
|
+
},
|
|
184
|
+
enforce_admins: false,
|
|
185
|
+
required_pull_request_reviews: null,
|
|
186
|
+
restrictions: null,
|
|
187
|
+
allow_force_pushes: false,
|
|
188
|
+
allow_deletions: false,
|
|
189
|
+
});
|
|
190
|
+
}
|