@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
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
name: Terraform apply
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
contents: read
|
|
9
|
+
|
|
10
|
+
concurrency:
|
|
11
|
+
group: terraform-apply
|
|
12
|
+
cancel-in-progress: false
|
|
13
|
+
|
|
14
|
+
env:
|
|
15
|
+
# The Scaleway keys double as S3 credentials for the state backend, so the
|
|
16
|
+
# same secret feeds both variables: one credential, one place to rotate.
|
|
17
|
+
AWS_ACCESS_KEY_ID: ${{ secrets.SCW_ACCESS_KEY }}
|
|
18
|
+
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCW_SECRET_KEY }}
|
|
19
|
+
SCW_ACCESS_KEY: ${{ secrets.SCW_ACCESS_KEY }}
|
|
20
|
+
SCW_SECRET_KEY: ${{ secrets.SCW_SECRET_KEY }}
|
|
21
|
+
SCW_DEFAULT_PROJECT_ID: ${{ secrets.SCW_DEFAULT_PROJECT_ID }}
|
|
22
|
+
SCW_DEFAULT_ORGANIZATION_ID: ${{ secrets.SCW_DEFAULT_ORGANIZATION_ID }}
|
|
23
|
+
INFISICAL_CLIENT_ID: ${{ secrets.INFISICAL_CLIENT_ID }}
|
|
24
|
+
INFISICAL_CLIENT_SECRET: ${{ secrets.INFISICAL_CLIENT_SECRET }}
|
|
25
|
+
INFISICAL_PROJECT_ID: ${{ vars.INFISICAL_PROJECT_ID }}
|
|
26
|
+
INFISICAL_HOST: ${{ vars.INFISICAL_HOST }}
|
|
27
|
+
TF_VAR_infisical_client_id: ${{ secrets.INFISICAL_CLIENT_ID }}
|
|
28
|
+
TF_VAR_infisical_client_secret: ${{ secrets.INFISICAL_CLIENT_SECRET }}
|
|
29
|
+
TF_VAR_infisical_project_id: ${{ vars.INFISICAL_PROJECT_ID }}
|
|
30
|
+
TF_VAR_infisical_host: ${{ vars.INFISICAL_HOST }}
|
|
31
|
+
TF_IN_AUTOMATION: "true"
|
|
32
|
+
|
|
33
|
+
jobs:
|
|
34
|
+
apply-staging:
|
|
35
|
+
name: apply (staging)
|
|
36
|
+
runs-on: ubuntu-latest
|
|
37
|
+
environment: staging
|
|
38
|
+
steps:
|
|
39
|
+
- uses: actions/checkout@v4
|
|
40
|
+
|
|
41
|
+
- uses: hashicorp/setup-terraform@v3
|
|
42
|
+
with:
|
|
43
|
+
terraform_version: "~1.12"
|
|
44
|
+
terraform_wrapper: false
|
|
45
|
+
|
|
46
|
+
- name: Write backend config
|
|
47
|
+
run: |
|
|
48
|
+
cat > backend.hcl <<EOF
|
|
49
|
+
bucket = "${{ vars.TF_STATE_BUCKET }}"
|
|
50
|
+
region = "${{ vars.SCW_REGION }}"
|
|
51
|
+
endpoints = {
|
|
52
|
+
s3 = "https://s3.${{ vars.SCW_REGION }}.scw.cloud"
|
|
53
|
+
}
|
|
54
|
+
EOF
|
|
55
|
+
|
|
56
|
+
- name: Terraform init
|
|
57
|
+
run: terraform init -input=false -backend-config=backend.hcl
|
|
58
|
+
|
|
59
|
+
- name: Select workspace
|
|
60
|
+
run: terraform workspace select -or-create staging
|
|
61
|
+
|
|
62
|
+
- name: Terraform apply
|
|
63
|
+
run: terraform apply -input=false -auto-approve -var-file=staging.tfvars
|
|
64
|
+
|
|
65
|
+
- name: Sync DATABASE_URL to Infisical
|
|
66
|
+
run: ./.github/scripts/sync-database-url.sh staging
|
|
67
|
+
|
|
68
|
+
apply-prod:
|
|
69
|
+
name: apply (prod)
|
|
70
|
+
runs-on: ubuntu-latest
|
|
71
|
+
needs: apply-staging
|
|
72
|
+
# The "production" GitHub Environment carries a required-reviewer rule,
|
|
73
|
+
# so this job waits for manual approval.
|
|
74
|
+
environment: production
|
|
75
|
+
steps:
|
|
76
|
+
- uses: actions/checkout@v4
|
|
77
|
+
|
|
78
|
+
- uses: hashicorp/setup-terraform@v3
|
|
79
|
+
with:
|
|
80
|
+
terraform_version: "~1.12"
|
|
81
|
+
terraform_wrapper: false
|
|
82
|
+
|
|
83
|
+
- name: Write backend config
|
|
84
|
+
run: |
|
|
85
|
+
cat > backend.hcl <<EOF
|
|
86
|
+
bucket = "${{ vars.TF_STATE_BUCKET }}"
|
|
87
|
+
region = "${{ vars.SCW_REGION }}"
|
|
88
|
+
endpoints = {
|
|
89
|
+
s3 = "https://s3.${{ vars.SCW_REGION }}.scw.cloud"
|
|
90
|
+
}
|
|
91
|
+
EOF
|
|
92
|
+
|
|
93
|
+
- name: Terraform init
|
|
94
|
+
run: terraform init -input=false -backend-config=backend.hcl
|
|
95
|
+
|
|
96
|
+
- name: Select workspace
|
|
97
|
+
run: terraform workspace select -or-create prod
|
|
98
|
+
|
|
99
|
+
- name: Terraform apply
|
|
100
|
+
run: terraform apply -input=false -auto-approve -var-file=prod.tfvars
|
|
101
|
+
|
|
102
|
+
- name: Sync DATABASE_URL to Infisical
|
|
103
|
+
run: ./.github/scripts/sync-database-url.sh prod
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
name: Drift detection
|
|
2
|
+
|
|
3
|
+
# Runs a read-only plan on a schedule and opens (or updates) an issue when the
|
|
4
|
+
# real infrastructure no longer matches the code, e.g. after a change made in
|
|
5
|
+
# the Scaleway console.
|
|
6
|
+
|
|
7
|
+
on:
|
|
8
|
+
schedule:
|
|
9
|
+
- cron: "0 6 * * 1" # every Monday, 06:00 UTC
|
|
10
|
+
workflow_dispatch: {}
|
|
11
|
+
|
|
12
|
+
permissions:
|
|
13
|
+
contents: read
|
|
14
|
+
issues: write
|
|
15
|
+
|
|
16
|
+
env:
|
|
17
|
+
# The Scaleway keys double as S3 credentials for the state backend, so the
|
|
18
|
+
# same secret feeds both variables: one credential, one place to rotate.
|
|
19
|
+
AWS_ACCESS_KEY_ID: ${{ secrets.SCW_ACCESS_KEY }}
|
|
20
|
+
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCW_SECRET_KEY }}
|
|
21
|
+
SCW_ACCESS_KEY: ${{ secrets.SCW_ACCESS_KEY }}
|
|
22
|
+
SCW_SECRET_KEY: ${{ secrets.SCW_SECRET_KEY }}
|
|
23
|
+
SCW_DEFAULT_PROJECT_ID: ${{ secrets.SCW_DEFAULT_PROJECT_ID }}
|
|
24
|
+
SCW_DEFAULT_ORGANIZATION_ID: ${{ secrets.SCW_DEFAULT_ORGANIZATION_ID }}
|
|
25
|
+
TF_VAR_infisical_client_id: ${{ secrets.INFISICAL_CLIENT_ID }}
|
|
26
|
+
TF_VAR_infisical_client_secret: ${{ secrets.INFISICAL_CLIENT_SECRET }}
|
|
27
|
+
TF_VAR_infisical_project_id: ${{ vars.INFISICAL_PROJECT_ID }}
|
|
28
|
+
TF_VAR_infisical_host: ${{ vars.INFISICAL_HOST }}
|
|
29
|
+
TF_IN_AUTOMATION: "true"
|
|
30
|
+
|
|
31
|
+
jobs:
|
|
32
|
+
drift:
|
|
33
|
+
name: drift (${{ matrix.environment }})
|
|
34
|
+
runs-on: ubuntu-latest
|
|
35
|
+
strategy:
|
|
36
|
+
fail-fast: false
|
|
37
|
+
matrix:
|
|
38
|
+
environment: [staging, prod]
|
|
39
|
+
steps:
|
|
40
|
+
- uses: actions/checkout@v4
|
|
41
|
+
|
|
42
|
+
- uses: hashicorp/setup-terraform@v3
|
|
43
|
+
with:
|
|
44
|
+
terraform_version: "~1.12"
|
|
45
|
+
terraform_wrapper: false
|
|
46
|
+
|
|
47
|
+
- name: Write backend config
|
|
48
|
+
run: |
|
|
49
|
+
cat > backend.hcl <<EOF
|
|
50
|
+
bucket = "${{ vars.TF_STATE_BUCKET }}"
|
|
51
|
+
region = "${{ vars.SCW_REGION }}"
|
|
52
|
+
endpoints = {
|
|
53
|
+
s3 = "https://s3.${{ vars.SCW_REGION }}.scw.cloud"
|
|
54
|
+
}
|
|
55
|
+
EOF
|
|
56
|
+
|
|
57
|
+
- name: Terraform init
|
|
58
|
+
run: terraform init -input=false -backend-config=backend.hcl
|
|
59
|
+
|
|
60
|
+
- name: Select workspace
|
|
61
|
+
run: terraform workspace select -or-create ${{ matrix.environment }}
|
|
62
|
+
|
|
63
|
+
- name: Terraform plan (drift check)
|
|
64
|
+
id: plan
|
|
65
|
+
# -lock=false: read-only check, must never contend with a real apply.
|
|
66
|
+
run: |
|
|
67
|
+
set +e
|
|
68
|
+
terraform plan -input=false -lock=false -detailed-exitcode \
|
|
69
|
+
-var-file=${{ matrix.environment }}.tfvars -no-color > plan.txt 2>&1
|
|
70
|
+
code=$?
|
|
71
|
+
set -e
|
|
72
|
+
echo "exitcode=$code" >> "$GITHUB_OUTPUT"
|
|
73
|
+
tail -50 plan.txt
|
|
74
|
+
if [ "$code" = "1" ]; then exit 1; fi
|
|
75
|
+
|
|
76
|
+
- name: Report drift
|
|
77
|
+
if: steps.plan.outputs.exitcode == '2'
|
|
78
|
+
env:
|
|
79
|
+
GH_TOKEN: ${{ github.token }}
|
|
80
|
+
ENVIRONMENT: ${{ matrix.environment }}
|
|
81
|
+
run: |
|
|
82
|
+
title="Infrastructure drift detected (${ENVIRONMENT})"
|
|
83
|
+
body="Scheduled \`terraform plan\` for **${ENVIRONMENT}** found differences between the code and the real infrastructure.
|
|
84
|
+
|
|
85
|
+
Review the [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) and either revert the manual change or align the code with a PR."
|
|
86
|
+
existing=$(gh issue list --state open --search "in:title \"$title\"" --json number --jq '.[0].number')
|
|
87
|
+
if [ -n "$existing" ]; then
|
|
88
|
+
gh issue comment "$existing" --body "Drift still present on $(date -u +%F). See ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
|
89
|
+
else
|
|
90
|
+
gh issue create --title "$title" --body "$body"
|
|
91
|
+
fi
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
name: Terraform plan
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
pull_request:
|
|
5
|
+
branches: [main]
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
contents: read
|
|
9
|
+
|
|
10
|
+
env:
|
|
11
|
+
# The Scaleway keys double as S3 credentials for the state backend, so the
|
|
12
|
+
# same secret feeds both variables: one credential, one place to rotate.
|
|
13
|
+
AWS_ACCESS_KEY_ID: ${{ secrets.SCW_ACCESS_KEY }}
|
|
14
|
+
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCW_SECRET_KEY }}
|
|
15
|
+
SCW_ACCESS_KEY: ${{ secrets.SCW_ACCESS_KEY }}
|
|
16
|
+
SCW_SECRET_KEY: ${{ secrets.SCW_SECRET_KEY }}
|
|
17
|
+
SCW_DEFAULT_PROJECT_ID: ${{ secrets.SCW_DEFAULT_PROJECT_ID }}
|
|
18
|
+
SCW_DEFAULT_ORGANIZATION_ID: ${{ secrets.SCW_DEFAULT_ORGANIZATION_ID }}
|
|
19
|
+
TF_VAR_infisical_client_id: ${{ secrets.INFISICAL_CLIENT_ID }}
|
|
20
|
+
TF_VAR_infisical_client_secret: ${{ secrets.INFISICAL_CLIENT_SECRET }}
|
|
21
|
+
TF_VAR_infisical_project_id: ${{ vars.INFISICAL_PROJECT_ID }}
|
|
22
|
+
TF_VAR_infisical_host: ${{ vars.INFISICAL_HOST }}
|
|
23
|
+
TF_IN_AUTOMATION: "true"
|
|
24
|
+
|
|
25
|
+
jobs:
|
|
26
|
+
plan:
|
|
27
|
+
name: plan (${{ matrix.environment }})
|
|
28
|
+
runs-on: ubuntu-latest
|
|
29
|
+
strategy:
|
|
30
|
+
fail-fast: false
|
|
31
|
+
matrix:
|
|
32
|
+
environment: [staging, prod]
|
|
33
|
+
steps:
|
|
34
|
+
- uses: actions/checkout@v4
|
|
35
|
+
|
|
36
|
+
- uses: hashicorp/setup-terraform@v3
|
|
37
|
+
with:
|
|
38
|
+
terraform_version: "~1.12"
|
|
39
|
+
|
|
40
|
+
- name: Terraform fmt
|
|
41
|
+
run: terraform fmt -check -recursive
|
|
42
|
+
|
|
43
|
+
- name: Write backend config
|
|
44
|
+
run: |
|
|
45
|
+
cat > backend.hcl <<EOF
|
|
46
|
+
bucket = "${{ vars.TF_STATE_BUCKET }}"
|
|
47
|
+
region = "${{ vars.SCW_REGION }}"
|
|
48
|
+
endpoints = {
|
|
49
|
+
s3 = "https://s3.${{ vars.SCW_REGION }}.scw.cloud"
|
|
50
|
+
}
|
|
51
|
+
EOF
|
|
52
|
+
|
|
53
|
+
- name: Terraform init
|
|
54
|
+
run: terraform init -input=false -backend-config=backend.hcl
|
|
55
|
+
|
|
56
|
+
- name: Select workspace
|
|
57
|
+
run: terraform workspace select -or-create ${{ matrix.environment }}
|
|
58
|
+
|
|
59
|
+
- name: Terraform validate
|
|
60
|
+
run: terraform validate
|
|
61
|
+
|
|
62
|
+
- name: Terraform plan
|
|
63
|
+
run: terraform plan -input=false -var-file=${{ matrix.environment }}.tfvars
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 __PROJECT_NAME__ contributors
|
|
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.
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# __PROJECT_NAME__
|
|
2
|
+
|
|
3
|
+
Serverless infrastructure on [Scaleway](https://www.scaleway.com), managed
|
|
4
|
+
with Terraform, deployed by GitHub Actions, with application secrets in
|
|
5
|
+
[Infisical](https://infisical.com). Generated by
|
|
6
|
+
[keel](https://github.com/Gambi97/keel).
|
|
7
|
+
|
|
8
|
+
This repository contains **infrastructure only**. The application itself
|
|
9
|
+
lives elsewhere: it is any Docker image pushed to the registry provisioned
|
|
10
|
+
here.
|
|
11
|
+
|
|
12
|
+
## What this repo manages
|
|
13
|
+
|
|
14
|
+
| Resource | Purpose |
|
|
15
|
+
|---|---|
|
|
16
|
+
| Registry namespace | Private container registry for the app images |
|
|
17
|
+
| Container namespace + Serverless Container | Runs the app, scales to zero when idle |
|
|
18
|
+
| Serverless SQL Database (Postgres) | One independent database per environment |
|
|
19
|
+
| IAM application + API key | Dedicated least-privilege database credential for the app |
|
|
20
|
+
|
|
21
|
+
Two environments, **staging** and **prod**, isolated via Terraform
|
|
22
|
+
workspaces: same code, two separate states, differences confined to
|
|
23
|
+
`staging.tfvars` / `prod.tfvars`. Staging expects the app to enforce Basic
|
|
24
|
+
Auth (see below). State lives in the `__PROJECT_NAME__-tfstate` bucket on
|
|
25
|
+
Scaleway Object Storage.
|
|
26
|
+
|
|
27
|
+
**There are no secrets in this repository**, and none should ever be added.
|
|
28
|
+
Credentials live in GitHub Actions encrypted secrets (CI) and in Infisical
|
|
29
|
+
(application).
|
|
30
|
+
|
|
31
|
+
> Naming heads-up: `backend.tf` configures the Terraform **state backend**,
|
|
32
|
+
> the bucket where Terraform records what it has created. It has nothing to
|
|
33
|
+
> do with your application's backend, which is just the Docker image you
|
|
34
|
+
> deploy.
|
|
35
|
+
|
|
36
|
+
## How deploys work
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
PR to main -> terraform fmt + validate + plan (staging & prod), shown as PR checks
|
|
40
|
+
merge/push main -> apply staging -> manual approval (production gate) -> apply prod
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
- `main` is protected: PRs need a green plan, force pushes are blocked.
|
|
44
|
+
- The prod apply waits for approval on the `production` GitHub Environment.
|
|
45
|
+
- After each apply, the pipeline writes a ready-to-use Postgres connection
|
|
46
|
+
string to Infisical as `DATABASE_URL` for that environment. The credential
|
|
47
|
+
inside it is a dedicated IAM application that can only read/write the
|
|
48
|
+
database (least privilege), not your main API key.
|
|
49
|
+
- State is locked during applies (S3-native locking, `use_lockfile`), so
|
|
50
|
+
concurrent runs cannot corrupt it.
|
|
51
|
+
- A scheduled **drift detection** workflow (Monday 06:00 UTC, or manual via
|
|
52
|
+
the Actions tab) runs a read-only plan and opens an issue if the real
|
|
53
|
+
infrastructure diverged from the code.
|
|
54
|
+
|
|
55
|
+
## First deploy
|
|
56
|
+
|
|
57
|
+
1. **Merge or push to `main`.** The first pipeline run creates registry and
|
|
58
|
+
databases. The Serverless Container is intentionally skipped until an
|
|
59
|
+
image exists (`container_image` is empty), so the first apply is green.
|
|
60
|
+
|
|
61
|
+
2. **Build and push the app image** (any Dockerfile, listening on port 8080
|
|
62
|
+
by default):
|
|
63
|
+
|
|
64
|
+
```sh
|
|
65
|
+
docker login rg.__REGION__.scw.cloud/__PROJECT_NAME__-staging -u nologin --password-stdin <<< "$SCW_SECRET_KEY"
|
|
66
|
+
docker build -t rg.__REGION__.scw.cloud/__PROJECT_NAME__-staging/app:latest .
|
|
67
|
+
docker push rg.__REGION__.scw.cloud/__PROJECT_NAME__-staging/app:latest
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
3. **Point the container at the image**: set `container_image` in
|
|
71
|
+
`staging.tfvars` (and later in `prod.tfvars`, using the prod registry),
|
|
72
|
+
open a PR, review the plan, merge. The apply creates the container and
|
|
73
|
+
prints its URL (`terraform output container_url`).
|
|
74
|
+
|
|
75
|
+
4. **Fill in the real secrets in Infisical.** The bootstrap seeded
|
|
76
|
+
placeholders; replace them with production values. The app reads
|
|
77
|
+
everything from its environment:
|
|
78
|
+
|
|
79
|
+
| Variable | Environment | Notes |
|
|
80
|
+
|---|---|---|
|
|
81
|
+
| `DATABASE_URL` | staging + prod | Complete connection string, auto-updated by the pipeline after each apply. No action needed |
|
|
82
|
+
| `BASIC_AUTH_USER` / `BASIC_AUTH_PASSWORD` | staging | The app must enforce these when `BASIC_AUTH_ENABLED=true` |
|
|
83
|
+
|
|
84
|
+
Every secret in the Infisical environment is injected into the container
|
|
85
|
+
as a secret environment variable, so adding an app secret is: add it in
|
|
86
|
+
Infisical, re-run the apply (any merge to `main`).
|
|
87
|
+
|
|
88
|
+
## Basic Auth on staging
|
|
89
|
+
|
|
90
|
+
Scaleway Serverless Containers expose public endpoints with no built-in auth,
|
|
91
|
+
so staging protection is enforced **by the application**: the container
|
|
92
|
+
receives `BASIC_AUTH_ENABLED=true` plus the credentials from Infisical, and
|
|
93
|
+
the app is expected to respond `401` without them (a few lines of middleware
|
|
94
|
+
in any framework). Prod does not set `BASIC_AUTH_ENABLED`.
|
|
95
|
+
|
|
96
|
+
## Day-2 operations
|
|
97
|
+
|
|
98
|
+
- **Scale**: adjust `min_scale` / `max_scale` (instances) in the tfvars;
|
|
99
|
+
`cpu_limit`, `memory_limit`, `container_port`, `db_min_cpu` / `db_max_cpu`
|
|
100
|
+
are variables with sensible defaults you can override the same way.
|
|
101
|
+
- **Rotate an app secret**: change it in Infisical, trigger an apply. No
|
|
102
|
+
commit needed.
|
|
103
|
+
- **Rotate the database credential**: `terraform apply
|
|
104
|
+
-replace=module.app_stack.scaleway_iam_api_key.db` (or via a PR touching
|
|
105
|
+
it); the pipeline re-syncs `DATABASE_URL` automatically.
|
|
106
|
+
- **Rotate Scaleway/Infisical credentials**: update the GitHub Actions
|
|
107
|
+
secrets (repo Settings > Secrets and variables > Actions).
|
|
108
|
+
- **Add a custom domain**: add a `scaleway_container_domain` resource in
|
|
109
|
+
`modules/app_stack` pointing at the container, plus your DNS record.
|
|
110
|
+
- **Add an environment**: add a workspace, an `<env>.tfvars`, an Infisical
|
|
111
|
+
environment with the same slug, and mirror one job in each workflow.
|
|
112
|
+
- **Pin provider versions**: after any local `terraform init`, commit the
|
|
113
|
+
generated `.terraform.lock.hcl` so CI resolves the exact same provider
|
|
114
|
+
builds on every run.
|
|
115
|
+
|
|
116
|
+
## Running Terraform locally (optional)
|
|
117
|
+
|
|
118
|
+
CI is the source of truth, but plans can be run locally:
|
|
119
|
+
|
|
120
|
+
```sh
|
|
121
|
+
cp backend.hcl.example backend.hcl
|
|
122
|
+
export AWS_ACCESS_KEY_ID=<scaleway-access-key> # state backend
|
|
123
|
+
export AWS_SECRET_ACCESS_KEY=<scaleway-secret-key>
|
|
124
|
+
export SCW_ACCESS_KEY=<scaleway-access-key> # provider
|
|
125
|
+
export SCW_SECRET_KEY=<scaleway-secret-key>
|
|
126
|
+
export SCW_DEFAULT_PROJECT_ID=<project-id>
|
|
127
|
+
export SCW_DEFAULT_ORGANIZATION_ID=<organization-id>
|
|
128
|
+
export TF_VAR_infisical_client_id=<machine-identity-client-id>
|
|
129
|
+
export TF_VAR_infisical_client_secret=<machine-identity-client-secret>
|
|
130
|
+
export TF_VAR_infisical_project_id=<infisical-project-id>
|
|
131
|
+
export TF_VAR_infisical_host=https://app.infisical.com
|
|
132
|
+
|
|
133
|
+
terraform init -backend-config=backend.hcl
|
|
134
|
+
terraform workspace select -or-create staging
|
|
135
|
+
terraform plan -var-file=staging.tfvars
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Avoid local applies: they race against CI on the same state.
|
|
139
|
+
|
|
140
|
+
## Configuration reference
|
|
141
|
+
|
|
142
|
+
- **Committed tfvars** (`staging.tfvars`, `prod.tfvars`): non-sensitive knobs
|
|
143
|
+
only (name, region, scaling, image).
|
|
144
|
+
- **GitHub Actions secrets**: `SCW_ACCESS_KEY`, `SCW_SECRET_KEY`,
|
|
145
|
+
`SCW_DEFAULT_PROJECT_ID`, `SCW_DEFAULT_ORGANIZATION_ID`,
|
|
146
|
+
`INFISICAL_CLIENT_ID`, `INFISICAL_CLIENT_SECRET`. The workflows reuse the
|
|
147
|
+
Scaleway keys as `AWS_*` for the state backend.
|
|
148
|
+
- **GitHub Actions variables**: `TF_STATE_BUCKET`, `SCW_REGION`,
|
|
149
|
+
`INFISICAL_PROJECT_ID`, `INFISICAL_HOST`.
|
|
150
|
+
- **Infisical**: one environment per workspace (`staging`, `prod`), all
|
|
151
|
+
application secrets.
|
|
152
|
+
|
|
153
|
+
## Troubleshooting
|
|
154
|
+
|
|
155
|
+
- **Plan fails with 403 on the state bucket**: the `SCW_*` secrets don't
|
|
156
|
+
match a Scaleway key with Object Storage access.
|
|
157
|
+
- **Plan fails reading Infisical secrets**: check `INFISICAL_PROJECT_ID` and
|
|
158
|
+
that the machine identity has access to the project and both environments.
|
|
159
|
+
- **Apply green but no container**: expected until `container_image` is set.
|
|
160
|
+
- **App can't reach the database**: `DATABASE_URL` is only synced after an
|
|
161
|
+
apply; check the value in Infisical for that environment. It must contain
|
|
162
|
+
a username and password (the dedicated IAM credential), not placeholders.
|
|
163
|
+
- **Apply fails creating IAM resources**: the CI Scaleway key needs IAM
|
|
164
|
+
permissions (IAMManager) to create the app's database credential.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Terraform (.terraform.lock.hcl is intentionally NOT ignored: commit it
|
|
2
|
+
# for reproducible provider versions)
|
|
3
|
+
.terraform/
|
|
4
|
+
*.tfstate
|
|
5
|
+
*.tfstate.*
|
|
6
|
+
crash.log
|
|
7
|
+
crash.*.log
|
|
8
|
+
*.tfplan
|
|
9
|
+
|
|
10
|
+
# Local backend config (contains bucket coordinates, kept out of the repo)
|
|
11
|
+
backend.hcl
|
|
12
|
+
|
|
13
|
+
# Local overrides and secrets: never commit credentials
|
|
14
|
+
*.auto.tfvars
|
|
15
|
+
override.tf
|
|
16
|
+
override.tf.json
|
|
17
|
+
*_override.tf
|
|
18
|
+
*_override.tf.json
|
|
19
|
+
.env
|
|
20
|
+
.env.*
|
|
21
|
+
|
|
22
|
+
# Bootstrap resume file
|
|
23
|
+
.keel.json
|
|
24
|
+
|
|
25
|
+
# Editors / OS
|
|
26
|
+
.DS_Store
|
|
27
|
+
.idea/
|
|
28
|
+
.vscode/
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# Copy to backend.hcl (git-ignored) for local terraform runs:
|
|
2
|
+
# cp backend.hcl.example backend.hcl
|
|
3
|
+
# terraform init -backend-config=backend.hcl
|
|
4
|
+
bucket = "__TF_STATE_BUCKET__"
|
|
5
|
+
region = "__REGION__"
|
|
6
|
+
|
|
7
|
+
endpoints = {
|
|
8
|
+
s3 = "https://s3.__REGION__.scw.cloud"
|
|
9
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Remote state on Scaleway Object Storage (S3-compatible).
|
|
2
|
+
# Bucket, region and endpoint are provided at init time:
|
|
3
|
+
# terraform init -backend-config=backend.hcl
|
|
4
|
+
# Credentials come from AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY
|
|
5
|
+
# (set them to your Scaleway access key / secret key).
|
|
6
|
+
# Each workspace stores its state under env:/<workspace>/.
|
|
7
|
+
# use_lockfile enables S3-native state locking (Terraform >= 1.10); Scaleway
|
|
8
|
+
# Object Storage supports the conditional writes it relies on.
|
|
9
|
+
terraform {
|
|
10
|
+
backend "s3" {
|
|
11
|
+
key = "terraform.tfstate"
|
|
12
|
+
use_lockfile = true
|
|
13
|
+
skip_credentials_validation = true
|
|
14
|
+
skip_region_validation = true
|
|
15
|
+
skip_requesting_account_id = true
|
|
16
|
+
skip_metadata_api_check = true
|
|
17
|
+
skip_s3_checksum = true
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Application secrets live in Infisical, one environment per Terraform workspace.
|
|
2
|
+
data "infisical_secrets" "app" {
|
|
3
|
+
env_slug = var.environment
|
|
4
|
+
workspace_id = var.infisical_project_id
|
|
5
|
+
folder_path = "/"
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
module "app_stack" {
|
|
9
|
+
source = "./modules/app_stack"
|
|
10
|
+
|
|
11
|
+
project_name = var.project_name
|
|
12
|
+
environment = var.environment
|
|
13
|
+
region = var.region
|
|
14
|
+
container_image = var.container_image
|
|
15
|
+
container_port = var.container_port
|
|
16
|
+
cpu_limit = var.cpu_limit
|
|
17
|
+
memory_limit = var.memory_limit
|
|
18
|
+
min_scale = var.min_scale
|
|
19
|
+
max_scale = var.max_scale
|
|
20
|
+
db_min_cpu = var.db_min_cpu
|
|
21
|
+
db_max_cpu = var.db_max_cpu
|
|
22
|
+
|
|
23
|
+
enable_basic_auth = var.enable_basic_auth
|
|
24
|
+
|
|
25
|
+
secret_environment_variables = {
|
|
26
|
+
for name, secret in data.infisical_secrets.app.secrets : name => secret.value
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
locals {
|
|
2
|
+
name = "${var.project_name}-${var.environment}"
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
resource "scaleway_registry_namespace" "this" {
|
|
6
|
+
name = local.name
|
|
7
|
+
region = var.region
|
|
8
|
+
is_public = false
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
resource "scaleway_container_namespace" "this" {
|
|
12
|
+
name = local.name
|
|
13
|
+
region = var.region
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
resource "scaleway_sdb_sql_database" "this" {
|
|
17
|
+
name = local.name
|
|
18
|
+
region = var.region
|
|
19
|
+
min_cpu = var.db_min_cpu
|
|
20
|
+
max_cpu = var.db_max_cpu
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
# Dedicated least-privilege credential for the application: it can reach the
|
|
24
|
+
# database and nothing else. Serverless SQL authenticates with IAM: username
|
|
25
|
+
# is the application ID, password its API secret key.
|
|
26
|
+
resource "scaleway_iam_application" "db" {
|
|
27
|
+
name = "${local.name}-db"
|
|
28
|
+
description = "Database access for ${local.name} (managed by Terraform)"
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
resource "scaleway_iam_policy" "db_access" {
|
|
32
|
+
name = "${local.name}-db-access"
|
|
33
|
+
description = "Read/write access to the ${local.name} Serverless SQL database"
|
|
34
|
+
application_id = scaleway_iam_application.db.id
|
|
35
|
+
|
|
36
|
+
rule {
|
|
37
|
+
project_ids = [scaleway_sdb_sql_database.this.project_id]
|
|
38
|
+
permission_set_names = ["ServerlessSQLDatabaseReadWrite"]
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
resource "scaleway_iam_api_key" "db" {
|
|
43
|
+
application_id = scaleway_iam_application.db.id
|
|
44
|
+
description = "Database credential for ${local.name} (managed by Terraform)"
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
# The container is gated on an image being available: registry and database
|
|
48
|
+
# are provisioned first, the container appears once an image has been pushed
|
|
49
|
+
# and container_image is set.
|
|
50
|
+
resource "scaleway_container" "this" {
|
|
51
|
+
count = var.container_image == "" ? 0 : 1
|
|
52
|
+
|
|
53
|
+
name = var.project_name
|
|
54
|
+
namespace_id = scaleway_container_namespace.this.id
|
|
55
|
+
image = var.container_image
|
|
56
|
+
port = var.container_port
|
|
57
|
+
cpu_limit = var.cpu_limit
|
|
58
|
+
memory_limit_bytes = var.memory_limit * 1024 * 1024
|
|
59
|
+
min_scale = var.min_scale
|
|
60
|
+
max_scale = var.max_scale
|
|
61
|
+
privacy = "public"
|
|
62
|
+
|
|
63
|
+
environment_variables = var.enable_basic_auth ? {
|
|
64
|
+
BASIC_AUTH_ENABLED = "true"
|
|
65
|
+
} : {}
|
|
66
|
+
|
|
67
|
+
# BASIC_AUTH_USER / BASIC_AUTH_PASSWORD / DATABASE_URL arrive here from
|
|
68
|
+
# Infisical; the app is responsible for enforcing Basic Auth when enabled.
|
|
69
|
+
secret_environment_variables = var.secret_environment_variables
|
|
70
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
output "container_url" {
|
|
2
|
+
value = var.container_image == "" ? null : scaleway_container.this[0].public_endpoint
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
output "registry_endpoint" {
|
|
6
|
+
value = scaleway_registry_namespace.this.endpoint
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
output "database_url" {
|
|
10
|
+
# Ready-to-use connection string: dedicated IAM application as username,
|
|
11
|
+
# its API secret key as password (see the connect-to-a-database docs).
|
|
12
|
+
value = format(
|
|
13
|
+
"postgres://%s:%s@%s",
|
|
14
|
+
scaleway_iam_application.db.id,
|
|
15
|
+
scaleway_iam_api_key.db.secret_key,
|
|
16
|
+
trimprefix(scaleway_sdb_sql_database.this.endpoint, "postgres://"),
|
|
17
|
+
)
|
|
18
|
+
sensitive = true
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
output "container_namespace_id" {
|
|
22
|
+
value = scaleway_container_namespace.this.id
|
|
23
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
variable "project_name" {
|
|
2
|
+
type = string
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
variable "environment" {
|
|
6
|
+
type = string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
variable "region" {
|
|
10
|
+
type = string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
variable "container_image" {
|
|
14
|
+
type = string
|
|
15
|
+
default = ""
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
variable "container_port" {
|
|
19
|
+
type = number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
variable "cpu_limit" {
|
|
23
|
+
type = number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
variable "memory_limit" {
|
|
27
|
+
type = number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
variable "min_scale" {
|
|
31
|
+
type = number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
variable "max_scale" {
|
|
35
|
+
type = number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
variable "db_min_cpu" {
|
|
39
|
+
type = number
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
variable "db_max_cpu" {
|
|
43
|
+
type = number
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
variable "enable_basic_auth" {
|
|
47
|
+
type = bool
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
variable "secret_environment_variables" {
|
|
51
|
+
description = "Secrets injected into the container, sourced from Infisical."
|
|
52
|
+
type = map(string)
|
|
53
|
+
sensitive = true
|
|
54
|
+
default = {}
|
|
55
|
+
}
|