@gambi97/keel-cli 0.2.1 → 0.3.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/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), a `DATABASE_URL` placeholder per environment, and `S3_*` placeholders when Object Storage is enabled |
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
- | Object Storage coordinates (opt-in) | Infisical (each env) | `S3_*` values synced by the pipeline after each apply |
213
- | Bucket, region, Infisical project | GitHub variables | Non-sensitive wiring, editable in one place |
214
- | Project name, scaling, image | Committed tfvars | Reviewable configuration, no secrets |
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
- await seedSecret(host, token, project.id, env.slug, 'BASIC_AUTH_USER', env.slug);
134
- await seedSecret(host, token, project.id, env.slug, 'BASIC_AUTH_PASSWORD', randomBytes(18).toString('base64url'));
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: validateScale(ov.minScale ?? def.minScale, `${slug} min scale`),
201
- maxScale: validateScale(ov.maxScale ?? def.maxScale, `${slug} max scale`),
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
- /** Root Terraform output consumed by .github/scripts/sync-secrets.sh. */
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}-${answers.environments[0].slug}`));
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@gambi97/keel-cli",
3
- "version": "0.2.1",
3
+ "version": "0.3.1",
4
4
  "description": "One command from zero to a production-shaped serverless infrastructure on Scaleway (Terraform + GitHub Actions + Infisical). Only Node required.",
5
5
  "keywords": [
6
6
  "keel",
@@ -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 (dedicated IAM credential included), plus
4
- # the S3_* Object Storage coordinates when that feature is enabled.
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
- SECRETS_JSON="$(terraform output -json infisical_secrets)"
13
- if [ -z "$SECRETS_JSON" ] || [ "$SECRETS_JSON" = "null" ] || [ "$SECRETS_JSON" = "{}" ]; then
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
@@ -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) and, when Object Storage is enabled,
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
@@ -19,7 +19,7 @@ override.tf.json
19
19
  .env
20
20
  .env.*
21
21
 
22
- # Bootstrap resume file
22
+ # Bootstrap resume file (machine-local; .keel/manifest.json IS committed)
23
23
  .keel.json
24
24
 
25
25
  # Editors / OS
@@ -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
 
@@ -24,13 +24,22 @@ output "object_bucket_name" {
24
24
  value = module.app_stack.object_bucket_name
25
25
  }
26
26
 
27
- # The complete set of secrets the pipeline pushes to Infisical after each apply:
28
- # always DATABASE_URL, plus the S3_* coordinates when Object Storage is enabled.
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
- { DATABASE_URL = module.app_stack.database_url },
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
@@ -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" {