@gambi97/keel-cli 0.2.1 → 0.3.0
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/README.md +26 -15
- package/dist/bootstrap/infisical.js +7 -4
- package/dist/config.js +7 -2
- package/dist/contracts.js +35 -1
- package/dist/generate.js +28 -0
- package/dist/index.js +4 -13
- package/dist/meta.js +12 -0
- package/package.json +1 -1
- package/templates/.github/scripts/sync-secrets.sh +13 -4
- package/templates/README.md +23 -2
- package/templates/_gitignore +1 -1
- package/templates/modules/app_stack/main.tf +3 -0
- package/templates/outputs.tf +12 -3
- package/templates/variables.tf +5 -0
package/README.md
CHANGED
|
@@ -163,12 +163,12 @@ local tooling or production credentials.
|
|
|
163
163
|
|
|
164
164
|
**Phase A: bootstrap (the CLI, via APIs, after your confirmation)**
|
|
165
165
|
|
|
166
|
-
| Where | What
|
|
167
|
-
| ------------ |
|
|
168
|
-
| Your machine | The generated repo: Terraform, workflows, README, initial git commit
|
|
169
|
-
| Scaleway | One Object Storage bucket for Terraform state (versioned, with native state locking, restricted by a bucket policy to the identity behind your API key)
|
|
170
|
-
| GitHub | Repository (public or private) pushed to `main`; encrypted Actions secrets; Actions variables; one deployment environment per selected environment (`production` gated by manual approval); branch protection on `main`
|
|
171
|
-
| Infisical | A project with one environment per selected environment, seeded with `BASIC_AUTH_USER` / `BASIC_AUTH_PASSWORD` (non-production, random password),
|
|
166
|
+
| Where | What |
|
|
167
|
+
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
168
|
+
| Your machine | The generated repo: Terraform, workflows, README, initial git commit |
|
|
169
|
+
| Scaleway | One Object Storage bucket for Terraform state (versioned, with native state locking, restricted by a bucket policy to the identity behind your API key) |
|
|
170
|
+
| GitHub | Repository (public or private) pushed to `main`; encrypted Actions secrets; Actions variables; one deployment environment per selected environment (`production` gated by manual approval); branch protection on `main` |
|
|
171
|
+
| Infisical | A project with one environment per selected environment, seeded with `BASIC_AUTH_USER` / `BASIC_AUTH_PASSWORD` (non-production, random password), `DATABASE_URL` / `APP_URL` placeholders per environment, and `S3_*` placeholders when Object Storage is enabled |
|
|
172
172
|
|
|
173
173
|
**Phase B: first deploy (Terraform in GitHub Actions, on push to `main`)**
|
|
174
174
|
|
|
@@ -192,6 +192,7 @@ my-app/
|
|
|
192
192
|
│ ├── terraform-plan.yml # PR: fmt + validate + plan (every environment)
|
|
193
193
|
│ ├── terraform-apply.yml # main: apply each env in order; production gated
|
|
194
194
|
│ └── terraform-drift.yml # weekly: read-only plan, opens an issue on drift
|
|
195
|
+
├── .keel/manifest.json # committed record: keel version, contract version, options
|
|
195
196
|
├── versions.tf · providers.tf · backend.tf
|
|
196
197
|
├── backend.hcl.example # state bucket coordinates (backend.hcl is git-ignored)
|
|
197
198
|
├── variables.tf · main.tf · outputs.tf
|
|
@@ -203,15 +204,16 @@ Environments are separated with **Terraform workspaces**: same code, one
|
|
|
203
204
|
independent state per environment in one bucket, differences confined to the
|
|
204
205
|
per-environment `<env>.tfvars` files.
|
|
205
206
|
|
|
206
|
-
| Data | Lives in | Why
|
|
207
|
-
| ----------------------------------- | ------------------------ |
|
|
208
|
-
| Scaleway API keys | GitHub encrypted secrets | CI needs them to run Terraform
|
|
209
|
-
| Infisical machine identity | GitHub encrypted secrets | Lets Terraform read app secrets at plan/apply
|
|
210
|
-
| Basic Auth user/password | Infisical (non-prod) | App secret, injected into the container, rotatable
|
|
211
|
-
| Database connection string | Infisical (each env) | Complete, ready-to-use value synced by the pipeline after each apply
|
|
212
|
-
|
|
|
213
|
-
|
|
|
214
|
-
|
|
|
207
|
+
| Data | Lives in | Why |
|
|
208
|
+
| ----------------------------------- | ------------------------ | ------------------------------------------------------------------------------------ |
|
|
209
|
+
| Scaleway API keys | GitHub encrypted secrets | CI needs them to run Terraform |
|
|
210
|
+
| Infisical machine identity | GitHub encrypted secrets | Lets Terraform read app secrets at plan/apply |
|
|
211
|
+
| Basic Auth user/password | Infisical (non-prod) | App secret, injected into the container, rotatable |
|
|
212
|
+
| Database connection string | Infisical (each env) | Complete, ready-to-use value synced by the pipeline after each apply |
|
|
213
|
+
| App public URL (`APP_URL`) | Infisical (each env) | Synced by the pipeline once a container exists; for links, OAuth callbacks, webhooks |
|
|
214
|
+
| Object Storage coordinates (opt-in) | Infisical (each env) | `S3_*` values synced by the pipeline after each apply |
|
|
215
|
+
| Bucket, region, Infisical project | GitHub variables | Non-sensitive wiring, editable in one place |
|
|
216
|
+
| Project name, scaling, image | Committed tfvars | Reviewable configuration, no secrets |
|
|
215
217
|
|
|
216
218
|
## After the bootstrap
|
|
217
219
|
|
|
@@ -378,6 +380,15 @@ Auth by default. To add one to an existing repo later: add a `<env>.tfvars`
|
|
|
378
380
|
the repo root automatically), an Infisical environment with the same slug, and
|
|
379
381
|
a chained job in `terraform-apply.yml`.
|
|
380
382
|
|
|
383
|
+
**Can I extend the generated repo with my own Terraform?**
|
|
384
|
+
Yes, and additions never edit generated files: drop a new `.tf` file at the
|
|
385
|
+
repo root (Terraform merges all root files), put per-env values in the
|
|
386
|
+
existing tfvars, and expose an output named `infisical_secrets_<name>` if your
|
|
387
|
+
module produces secrets the app should read — the pipeline collects every
|
|
388
|
+
output matching that prefix and syncs it to Infisical automatically. The
|
|
389
|
+
committed `.keel/manifest.json` records the contract version your repo was
|
|
390
|
+
generated with.
|
|
391
|
+
|
|
381
392
|
**Can I store files, not just rows?**
|
|
382
393
|
Yes, opt in with `--object-storage` (or answer yes interactively). Each
|
|
383
394
|
environment gets its own Object Storage bucket and a dedicated credential; the
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto';
|
|
2
|
-
import { S3_SECRET_KEYS } from '../contracts.js';
|
|
2
|
+
import { BASE_SYNCED_KEYS, BASIC_AUTH_SECRET_KEYS, S3_SECRET_KEYS } from '../contracts.js';
|
|
3
3
|
export class InfisicalError extends Error {
|
|
4
4
|
field;
|
|
5
5
|
constructor(message, field = 'credentials') {
|
|
@@ -130,10 +130,13 @@ export async function bootstrapInfisical(answers) {
|
|
|
130
130
|
const placeholder = 'placeholder-updated-by-pipeline-after-first-apply';
|
|
131
131
|
for (const env of answers.environments) {
|
|
132
132
|
if (env.basicAuth) {
|
|
133
|
-
|
|
134
|
-
await seedSecret(host, token, project.id, env.slug,
|
|
133
|
+
const [userKey, passwordKey] = BASIC_AUTH_SECRET_KEYS;
|
|
134
|
+
await seedSecret(host, token, project.id, env.slug, userKey, env.slug);
|
|
135
|
+
await seedSecret(host, token, project.id, env.slug, passwordKey, randomBytes(18).toString('base64url'));
|
|
136
|
+
}
|
|
137
|
+
for (const key of BASE_SYNCED_KEYS) {
|
|
138
|
+
await seedSecret(host, token, project.id, env.slug, key, placeholder);
|
|
135
139
|
}
|
|
136
|
-
await seedSecret(host, token, project.id, env.slug, 'DATABASE_URL', placeholder);
|
|
137
140
|
if (answers.objectStorage) {
|
|
138
141
|
for (const key of S3_SECRET_KEYS) {
|
|
139
142
|
await seedSecret(host, token, project.id, env.slug, key, placeholder);
|
package/dist/config.js
CHANGED
|
@@ -189,6 +189,11 @@ export function resolveEnvironments(slugs, basicAuth, overrides) {
|
|
|
189
189
|
return slugs.map((slug) => {
|
|
190
190
|
const def = ENV_DEFAULTS[slug];
|
|
191
191
|
const ov = overrides[slug] ?? {};
|
|
192
|
+
const minScale = validateScale(ov.minScale ?? def.minScale, `${slug} min scale`);
|
|
193
|
+
const maxScale = validateScale(ov.maxScale ?? def.maxScale, `${slug} max scale`);
|
|
194
|
+
if (minScale > maxScale) {
|
|
195
|
+
throw new ConfigError(`Invalid ${slug} scaling: min scale (${minScale}) cannot exceed max scale (${maxScale}).`);
|
|
196
|
+
}
|
|
192
197
|
return {
|
|
193
198
|
slug,
|
|
194
199
|
displayName: def.displayName,
|
|
@@ -197,8 +202,8 @@ export function resolveEnvironments(slugs, basicAuth, overrides) {
|
|
|
197
202
|
gated: def.gated,
|
|
198
203
|
// Basic Auth is a non-production safety net; production is never gated by it.
|
|
199
204
|
basicAuth: def.production ? false : basicAuth,
|
|
200
|
-
minScale
|
|
201
|
-
maxScale
|
|
205
|
+
minScale,
|
|
206
|
+
maxScale,
|
|
202
207
|
};
|
|
203
208
|
});
|
|
204
209
|
}
|
package/dist/contracts.js
CHANGED
|
@@ -15,8 +15,42 @@
|
|
|
15
15
|
export function planStatusCheckContext(environment) {
|
|
16
16
|
return `plan (${environment})`;
|
|
17
17
|
}
|
|
18
|
-
/**
|
|
18
|
+
/**
|
|
19
|
+
* Version of the docking contract between keel and the generated repository
|
|
20
|
+
* (output naming, manifest schema, tfvars conventions). Recorded in the
|
|
21
|
+
* committed .keel/manifest.json; future tooling that extends a generated
|
|
22
|
+
* repo checks it before touching anything.
|
|
23
|
+
*/
|
|
24
|
+
export const CONTRACT_VERSION = 1;
|
|
25
|
+
/** Base Terraform output consumed by .github/scripts/sync-secrets.sh. */
|
|
19
26
|
export const INFISICAL_SECRETS_OUTPUT = 'infisical_secrets';
|
|
27
|
+
/**
|
|
28
|
+
* Regex (verbatim in sync-secrets.sh) matching the base output plus any
|
|
29
|
+
* module-contributed "infisical_secrets_<name>" output. A module added to a
|
|
30
|
+
* generated repo exposes its own map under that name and the pipeline syncs
|
|
31
|
+
* it — no edit to outputs.tf needed (file-additive extension).
|
|
32
|
+
*/
|
|
33
|
+
export const INFISICAL_SECRETS_OUTPUT_PATTERN = '^infisical_secrets(_[a-z0-9_]+)?$';
|
|
34
|
+
/**
|
|
35
|
+
* Keys always present in the synced map: seeded as placeholders by the CLI,
|
|
36
|
+
* produced by the root outputs, overwritten by the pipeline after each apply.
|
|
37
|
+
*/
|
|
38
|
+
export const BASE_SYNCED_KEYS = ['DATABASE_URL', 'APP_URL'];
|
|
39
|
+
/**
|
|
40
|
+
* Basic Auth crosses the boundary in three places: the CLI seeds these two
|
|
41
|
+
* secrets in Infisical (non-production), the generated app_stack injects the
|
|
42
|
+
* flag below, and the user's app enforces it. [user, password] in this order.
|
|
43
|
+
*/
|
|
44
|
+
export const BASIC_AUTH_SECRET_KEYS = ['BASIC_AUTH_USER', 'BASIC_AUTH_PASSWORD'];
|
|
45
|
+
export const BASIC_AUTH_FLAG = 'BASIC_AUTH_ENABLED';
|
|
46
|
+
/**
|
|
47
|
+
* Naming convention for per-environment resources (registry, namespaces,
|
|
48
|
+
* database, buckets…): mirrored by `local.name` in the app_stack template,
|
|
49
|
+
* which contracts.test.ts pins against this function.
|
|
50
|
+
*/
|
|
51
|
+
export function resourceName(projectName, envSlug) {
|
|
52
|
+
return `${projectName}-${envSlug}`;
|
|
53
|
+
}
|
|
20
54
|
/**
|
|
21
55
|
* Object Storage coordinates: seeded as placeholders by the CLI, produced by
|
|
22
56
|
* the infisical_secrets output, synced to Infisical by the pipeline.
|
package/dist/generate.js
CHANGED
|
@@ -2,9 +2,33 @@ import { spawnSync } from 'node:child_process';
|
|
|
2
2
|
import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { CONTRACT_VERSION } from './contracts.js';
|
|
6
|
+
import { toolVersion } from './meta.js';
|
|
5
7
|
import { STATE_FILE } from './state.js';
|
|
6
8
|
export class GenerateError extends Error {
|
|
7
9
|
}
|
|
10
|
+
/**
|
|
11
|
+
* Machine-readable record of how the repository was generated. Unlike the
|
|
12
|
+
* git-ignored .keel.json resume file, the manifest is committed: future
|
|
13
|
+
* tooling that extends a generated repo (e.g. `keel add`) reads it from a
|
|
14
|
+
* fresh clone to know which contract it is docking onto.
|
|
15
|
+
*/
|
|
16
|
+
export const MANIFEST_FILE = '.keel/manifest.json';
|
|
17
|
+
function renderManifest(answers) {
|
|
18
|
+
const manifest = {
|
|
19
|
+
keelVersion: toolVersion(),
|
|
20
|
+
contractVersion: CONTRACT_VERSION,
|
|
21
|
+
generatedAt: new Date().toISOString(),
|
|
22
|
+
projectName: answers.projectName,
|
|
23
|
+
region: answers.region,
|
|
24
|
+
environments: answers.environments.map((e) => e.slug),
|
|
25
|
+
options: {
|
|
26
|
+
objectStorage: answers.objectStorage,
|
|
27
|
+
basicAuth: answers.basicAuth,
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
return `${JSON.stringify(manifest, null, 2)}\n`;
|
|
31
|
+
}
|
|
8
32
|
/** Files whose real name would confuse npm packaging are stored renamed. */
|
|
9
33
|
const RENAMES = {
|
|
10
34
|
_gitignore: '.gitignore',
|
|
@@ -127,6 +151,10 @@ export function generateProject(answers, options = {}) {
|
|
|
127
151
|
const applyDestRel = '.github/workflows/terraform-apply.yml';
|
|
128
152
|
writeFileSync(join(target, applyDestRel), renderApplyWorkflow(source, answers, tokens));
|
|
129
153
|
written.push(applyDestRel);
|
|
154
|
+
// Committed manifest recording generator version, contract and options.
|
|
155
|
+
mkdirSync(join(target, '.keel'), { recursive: true });
|
|
156
|
+
writeFileSync(join(target, MANIFEST_FILE), renderManifest(answers));
|
|
157
|
+
written.push(MANIFEST_FILE);
|
|
130
158
|
// backend.hcl (git-ignored) so local terraform runs work out of the box.
|
|
131
159
|
cpSync(join(target, 'backend.hcl.example'), join(target, 'backend.hcl'));
|
|
132
160
|
if (options.git !== false) {
|
package/dist/index.js
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
3
|
import { spawnSync } from 'node:child_process';
|
|
4
4
|
import { parseArgs } from 'node:util';
|
|
5
|
-
import { fileURLToPath } from 'node:url';
|
|
6
5
|
import { ConfigError, finalizeAnswers, fromEnv, mergeAnswers, missingRequired, parseEnvironments, } from './config.js';
|
|
7
|
-
import { CI_SECRET_NAMES, CI_VARIABLE_NAMES } from './contracts.js';
|
|
6
|
+
import { CI_SECRET_NAMES, CI_VARIABLE_NAMES, resourceName } from './contracts.js';
|
|
8
7
|
import { generateProject, GenerateError } from './generate.js';
|
|
8
|
+
import { toolVersion } from './meta.js';
|
|
9
9
|
import { confirmSummary, fillMissing } from './prompts.js';
|
|
10
10
|
import { isDone, loadState, markDone, STATE_FILE, stepData, } from './state.js';
|
|
11
11
|
import { cancel, intro, log, outro, renderNextSteps, renderSummary, withSpinner } from './ui.js';
|
|
@@ -211,15 +211,6 @@ function normalizeConfigFile(raw) {
|
|
|
211
211
|
scaling: (obj.scaling ?? {}),
|
|
212
212
|
});
|
|
213
213
|
}
|
|
214
|
-
function toolVersion() {
|
|
215
|
-
try {
|
|
216
|
-
const pkg = JSON.parse(readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8'));
|
|
217
|
-
return pkg.version;
|
|
218
|
-
}
|
|
219
|
-
catch {
|
|
220
|
-
return '0.0.0';
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
214
|
function checkEnvironment() {
|
|
224
215
|
const [major] = process.versions.node.split('.');
|
|
225
216
|
if (Number(major) < 18) {
|
|
@@ -239,7 +230,7 @@ function printDryRunPlan(answers) {
|
|
|
239
230
|
` - Infisical: ${answers.infisical.projectId
|
|
240
231
|
? `reuse project ${answers.infisical.projectId}`
|
|
241
232
|
: `create/reuse project "${answers.infisical.projectName}"`}, environments ${envList},`,
|
|
242
|
-
' seed BASIC_AUTH_USER/BASIC_AUTH_PASSWORD (non-prod) and DATABASE_URL placeholders' +
|
|
233
|
+
' seed BASIC_AUTH_USER/BASIC_AUTH_PASSWORD (non-prod) and DATABASE_URL/APP_URL placeholders' +
|
|
243
234
|
(answers.objectStorage ? ' and S3_* placeholders' : ''),
|
|
244
235
|
` - GitHub: create ${answers.github.repoPrivate ? 'private' : 'public'} repo "${answers.github.repoName}", push, set ${CI_SECRET_NAMES.length} encrypted secrets,`,
|
|
245
236
|
` ${CI_VARIABLE_NAMES.length} variables, ${ghEnvs} environments and main branch protection`,
|
|
@@ -389,7 +380,7 @@ async function main() {
|
|
|
389
380
|
return;
|
|
390
381
|
}
|
|
391
382
|
const repoUrl = await runBootstrap(answers, state, { preValidated: interactive });
|
|
392
|
-
outro(renderNextSteps(answers, repoUrl, `rg.${answers.region}.scw.cloud/${answers.projectName
|
|
383
|
+
outro(renderNextSteps(answers, repoUrl, `rg.${answers.region}.scw.cloud/${resourceName(answers.projectName, answers.environments[0].slug)}`));
|
|
393
384
|
}
|
|
394
385
|
main().catch((error) => {
|
|
395
386
|
if (error instanceof ConfigError || error instanceof GenerateError) {
|
package/dist/meta.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
/** Version of the running keel package (dist/ sits next to package.json). */
|
|
4
|
+
export function toolVersion() {
|
|
5
|
+
try {
|
|
6
|
+
const pkg = JSON.parse(readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8'));
|
|
7
|
+
return pkg.version;
|
|
8
|
+
}
|
|
9
|
+
catch {
|
|
10
|
+
return '0.0.0';
|
|
11
|
+
}
|
|
12
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# Pushes the secrets Terraform produced to Infisical, so the application reads
|
|
3
|
-
# them at runtime: always DATABASE_URL
|
|
4
|
-
#
|
|
3
|
+
# them at runtime: always DATABASE_URL and APP_URL, plus the S3_* Object
|
|
4
|
+
# Storage coordinates when that feature is enabled.
|
|
5
5
|
# Usage: sync-secrets.sh <environment>
|
|
6
6
|
# Expects: INFISICAL_HOST, INFISICAL_CLIENT_ID, INFISICAL_CLIENT_SECRET,
|
|
7
7
|
# INFISICAL_PROJECT_ID in the environment; terraform init already run.
|
|
@@ -9,8 +9,17 @@ set -euo pipefail
|
|
|
9
9
|
|
|
10
10
|
ENVIRONMENT="${1:?usage: sync-secrets.sh <environment>}"
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
# Collect the base "infisical_secrets" output plus any module-contributed
|
|
13
|
+
# "infisical_secrets_<name>" output, merged into one map. A module added to
|
|
14
|
+
# this repo contributes secrets by exposing its own output under that prefix:
|
|
15
|
+
# nothing here or in outputs.tf needs editing.
|
|
16
|
+
SECRETS_JSON="$(terraform output -json | jq -c '
|
|
17
|
+
[to_entries[]
|
|
18
|
+
| select(.key | test("^infisical_secrets(_[a-z0-9_]+)?$"))
|
|
19
|
+
| select(.value.value | type == "object")
|
|
20
|
+
| .value.value]
|
|
21
|
+
| add // {}')"
|
|
22
|
+
if [ "$SECRETS_JSON" = "{}" ] || [ "$SECRETS_JSON" = "null" ]; then
|
|
14
23
|
echo "No secrets to sync."
|
|
15
24
|
exit 0
|
|
16
25
|
fi
|
package/templates/README.md
CHANGED
|
@@ -49,8 +49,8 @@ merge/push main -> apply each environment in order; any gated environment (pro
|
|
|
49
49
|
- After each apply, the pipeline syncs the secrets Terraform produced to
|
|
50
50
|
Infisical for that environment: a ready-to-use Postgres `DATABASE_URL`
|
|
51
51
|
(whose credential is a dedicated IAM application that can only read/write
|
|
52
|
-
the database, not your main API key)
|
|
53
|
-
the `S3_*` coordinates.
|
|
52
|
+
the database, not your main API key), the app's public `APP_URL` and, when
|
|
53
|
+
Object Storage is enabled, the `S3_*` coordinates.
|
|
54
54
|
- State is locked during applies (S3-native locking, `use_lockfile`), so
|
|
55
55
|
concurrent runs cannot corrupt it.
|
|
56
56
|
- A scheduled **drift detection** workflow (Monday 06:00 UTC, or manual via
|
|
@@ -84,6 +84,7 @@ merge/push main -> apply each environment in order; any gated environment (pro
|
|
|
84
84
|
| Variable | Where | Notes |
|
|
85
85
|
|---|---|---|
|
|
86
86
|
| `DATABASE_URL` | every environment | Complete connection string, auto-updated by the pipeline after each apply. No action needed |
|
|
87
|
+
| `APP_URL` | every environment | Public URL of the app, auto-updated by the pipeline once a container exists. Useful for links, OAuth callbacks, webhooks |
|
|
87
88
|
| `BASIC_AUTH_USER` / `BASIC_AUTH_PASSWORD` | non-production | The app must enforce these when `BASIC_AUTH_ENABLED=true` |
|
|
88
89
|
| `S3_BUCKET` / `S3_ENDPOINT` / `S3_REGION` / `S3_ACCESS_KEY` / `S3_SECRET_KEY` | every environment (if Object Storage enabled) | Auto-updated by the pipeline after each apply |
|
|
89
90
|
|
|
@@ -125,6 +126,26 @@ environment enables it is the `enable_basic_auth` flag in its `<env>.tfvars`.
|
|
|
125
126
|
generated `.terraform.lock.hcl` so CI resolves the exact same provider
|
|
126
127
|
builds on every run.
|
|
127
128
|
|
|
129
|
+
## Extending this repo with your own module
|
|
130
|
+
|
|
131
|
+
The layout is designed so additions never edit generated files:
|
|
132
|
+
|
|
133
|
+
1. Put your Terraform in a **new file at the repo root** (e.g. `jobs.tf`,
|
|
134
|
+
optionally with a `modules/<name>/` directory) — Terraform merges all root
|
|
135
|
+
`.tf` files automatically. Reach the app stack only through its outputs,
|
|
136
|
+
never into its internals.
|
|
137
|
+
2. Per-environment configuration goes into the existing `<env>.tfvars` files
|
|
138
|
+
(append new variables; declare them in your own `.tf` file).
|
|
139
|
+
3. If your module produces secrets the app should read, expose them as an
|
|
140
|
+
output named `infisical_secrets_<name>` (a map of string, marked
|
|
141
|
+
`sensitive`). The pipeline collects every output matching that prefix and
|
|
142
|
+
syncs it to Infisical after each apply — no edit to `outputs.tf` or the
|
|
143
|
+
workflows required.
|
|
144
|
+
|
|
145
|
+
`.keel/manifest.json` (committed) records the keel version, the contract
|
|
146
|
+
version and the generation options this repo was created with, so tooling and
|
|
147
|
+
future you know exactly what it is built on.
|
|
148
|
+
|
|
128
149
|
## Running Terraform locally (optional)
|
|
129
150
|
|
|
130
151
|
CI is the source of truth, but plans can be run locally (replace `<env>` with
|
package/templates/_gitignore
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
locals {
|
|
2
|
+
# <project>-<env>: the naming convention every per-environment resource
|
|
3
|
+
# shares with the CLI (which builds registry hints from it) — do not change
|
|
4
|
+
# one side without the other; contracts.test.ts pins them together.
|
|
2
5
|
name = "${var.project_name}-${var.environment}"
|
|
3
6
|
}
|
|
4
7
|
|
package/templates/outputs.tf
CHANGED
|
@@ -24,13 +24,22 @@ output "object_bucket_name" {
|
|
|
24
24
|
value = module.app_stack.object_bucket_name
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
# The
|
|
28
|
-
#
|
|
27
|
+
# The secrets the pipeline pushes to Infisical after each apply: always
|
|
28
|
+
# DATABASE_URL and APP_URL, plus the S3_* coordinates when Object Storage is
|
|
29
|
+
# enabled. A module added to this repo can contribute its own secrets by
|
|
30
|
+
# exposing an output named "infisical_secrets_<name>" (a map of string): the
|
|
31
|
+
# pipeline collects every output matching that prefix, so extending never
|
|
32
|
+
# requires editing this file.
|
|
29
33
|
output "infisical_secrets" {
|
|
30
34
|
description = "Secrets synced to Infisical after each apply."
|
|
31
35
|
sensitive = true
|
|
32
36
|
value = merge(
|
|
33
|
-
{
|
|
37
|
+
{
|
|
38
|
+
DATABASE_URL = module.app_stack.database_url
|
|
39
|
+
# Public URL of the app; null until a container image is deployed (the
|
|
40
|
+
# pipeline skips null values and syncs it on the first apply after).
|
|
41
|
+
APP_URL = module.app_stack.container_url
|
|
42
|
+
},
|
|
34
43
|
var.enable_object_storage ? {
|
|
35
44
|
S3_BUCKET = module.app_stack.object_bucket_name
|
|
36
45
|
S3_ENDPOINT = module.app_stack.object_bucket_endpoint
|
package/templates/variables.tf
CHANGED
|
@@ -46,6 +46,11 @@ variable "min_scale" {
|
|
|
46
46
|
description = "Minimum number of container instances."
|
|
47
47
|
type = number
|
|
48
48
|
default = 0
|
|
49
|
+
|
|
50
|
+
validation {
|
|
51
|
+
condition = var.min_scale <= var.max_scale
|
|
52
|
+
error_message = "min_scale cannot exceed max_scale."
|
|
53
|
+
}
|
|
49
54
|
}
|
|
50
55
|
|
|
51
56
|
variable "max_scale" {
|