fix(deploy): commit production's compose and bring it under the drift guard (#118) #145
@@ -1,28 +1,67 @@
|
|||||||
import { readFileSync } from 'fs';
|
import { readFileSync, readdirSync } from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { ALWAYS_REQUIRED } from '../../src/envValidation';
|
import { ALWAYS_REQUIRED, validateEnv } from '../../src/envValidation';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The guard for #107.
|
* The guard for #107 and #118.
|
||||||
*
|
*
|
||||||
* That failure was not the missing variable — it was that nothing connected two
|
* #107 was not the missing variable — it was that nothing connected two files.
|
||||||
* files. envValidation.ts gained a required variable and docker-compose.qa.yml
|
* envValidation.ts gained a required variable and docker-compose.qa.yml did
|
||||||
* did not set it, and nothing noticed until the container refused to boot on a
|
* not set it, and nothing noticed until the container refused to boot on a
|
||||||
* deploy. CI passed throughout, because CI sets its own environment and never
|
* deploy. CI passed throughout, because CI sets its own environment and never
|
||||||
* reads the compose file.
|
* reads the compose file.
|
||||||
*
|
*
|
||||||
* So this reads the real list from the validator rather than a copy. A copy
|
* So this reads the real list from the validator rather than a copy. A copy
|
||||||
* would pass forever while the next added variable went unguarded in precisely
|
* would pass forever while the next added variable went unguarded in precisely
|
||||||
* the same way.
|
* the same way. For the same reason the per-file check below runs `validateEnv`
|
||||||
|
* itself rather than restating its rules: the point is that each deploying file
|
||||||
|
* satisfies the validator, and any restatement here is one more thing to drift.
|
||||||
*
|
*
|
||||||
* What this cannot cover: production runs from a Portainer stack outside this
|
* #118 was the other half. This used to read QA's file alone, while production
|
||||||
* repository, so nothing here can check it. Adding a required variable still
|
* ran from a Portainer stack outside the repository that nothing could check.
|
||||||
* means updating that stack by hand, and this test is not evidence that it was
|
* That was worse than an even gap, because a green test implied a coverage it
|
||||||
* done.
|
* did not have: UPLOADS_DIR is in ALWAYS_REQUIRED, QA set it, production did
|
||||||
|
* not, and on 2026-08-23 production refused to boot for exactly that reason —
|
||||||
|
* while the variable was set in Portainer, where stack variables are
|
||||||
|
* substituted into the compose file rather than handed to the container, so a
|
||||||
|
* variable with no line in the file never reaches the app at all.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const COMPOSE_PATH = path.resolve(__dirname, '..', '..', '..', 'docker-compose.qa.yml');
|
const REPO_ROOT = path.resolve(__dirname, '..', '..', '..');
|
||||||
const compose = readFileSync(COMPOSE_PATH, 'utf8');
|
|
||||||
|
/**
|
||||||
|
* Every deployment this repository describes, and what is true of each.
|
||||||
|
*
|
||||||
|
* Registered rather than discovered blindly, because the environments differ on
|
||||||
|
* purpose — QA runs in demo mode with no PayPal credentials and a mail
|
||||||
|
* allowlist, production is the reverse of all three — so "the files agree with
|
||||||
|
* each other" would be the wrong assertion. What each file has to satisfy is
|
||||||
|
* the validator, plus the handful of things below that are properties of that
|
||||||
|
* environment rather than of the code.
|
||||||
|
*
|
||||||
|
* A compose file at the repository root that is not registered here fails the
|
||||||
|
* last test in this file. That is deliberate: adding an environment should
|
||||||
|
* force the decision about what is true of it, rather than silently inheriting
|
||||||
|
* whatever the loop happened to assert.
|
||||||
|
*/
|
||||||
|
const DEPLOYMENTS = [
|
||||||
|
{
|
||||||
|
file: 'docker-compose.qa.yml',
|
||||||
|
demoMode: 'true',
|
||||||
|
// The entire safety property of QA: delivery is restricted to named
|
||||||
|
// recipients, so a run against a database full of test fixtures cannot
|
||||||
|
// email a real customer. Asserted because the compose file claims it must
|
||||||
|
// not depend on somebody remembering to set something.
|
||||||
|
requiresMailAllowlist: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
file: 'docker-compose.prod.yml',
|
||||||
|
demoMode: 'false',
|
||||||
|
// Deliberately unrestricted. Production has to be able to reach real
|
||||||
|
// customers, and it is the one environment where that is correct.
|
||||||
|
requiresMailAllowlist: false
|
||||||
|
}
|
||||||
|
] as const;
|
||||||
|
|
||||||
// Only real environment entries — `- NAME=value` at an indented list position.
|
// Only real environment entries — `- NAME=value` at an indented list position.
|
||||||
// A mention inside a comment cannot match, because a comment line starts with #.
|
// A mention inside a comment cannot match, because a comment line starts with #.
|
||||||
@@ -43,9 +82,10 @@ function environmentEntries(source: string): Map<string, string> {
|
|||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
const entries = environmentEntries(compose);
|
describe.each(DEPLOYMENTS)('$file provides everything the app requires to boot', (deployment) => {
|
||||||
|
const compose = readFileSync(path.join(REPO_ROOT, deployment.file), 'utf8');
|
||||||
|
const entries = environmentEntries(compose);
|
||||||
|
|
||||||
describe('the QA compose file provides everything the app requires to boot', () => {
|
|
||||||
it('parsed some environment entries at all', () => {
|
it('parsed some environment entries at all', () => {
|
||||||
// Guards the guard: a regex that matched nothing would make every
|
// Guards the guard: a regex that matched nothing would make every
|
||||||
// assertion below vacuously true.
|
// assertion below vacuously true.
|
||||||
@@ -56,17 +96,32 @@ describe('the QA compose file provides everything the app requires to boot', ()
|
|||||||
expect(entries.has(name)).toBe(true);
|
expect(entries.has(name)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
// DEMO_MODE is required too, but validated separately from ALWAYS_REQUIRED
|
/**
|
||||||
// because its rule is stricter than presence — it must be exactly 'true' or
|
* The strongest form of this check, and the one that cannot drift: hand the
|
||||||
// 'false'. Named explicitly here so it is not missed by reading only the list.
|
* file's own entries to the real validator and require it to be satisfied.
|
||||||
it('sets DEMO_MODE, to one of the two values that are allowed', () => {
|
*
|
||||||
expect(entries.has('DEMO_MODE')).toBe(true);
|
* An interpolated `${SECRET}` counts as present, which is correct — what is
|
||||||
expect(['true', 'false']).toContain(entries.get('DEMO_MODE'));
|
* being checked is that the *line exists*, since that is what decides whether
|
||||||
|
* the value reaches the container. Whether the stack variable behind it is
|
||||||
|
* set is a different failure, and one the app already reports clearly at boot.
|
||||||
|
*
|
||||||
|
* Errors only. Production warns about MAIL_ALLOWLIST by design, and silencing
|
||||||
|
* that warning would remove the notice that this environment can email real
|
||||||
|
* customers.
|
||||||
|
*/
|
||||||
|
it('satisfies validateEnv, the same check the container runs at boot', () => {
|
||||||
|
const { errors } = validateEnv(Object.fromEntries(entries));
|
||||||
|
expect(errors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`sets DEMO_MODE to ${deployment.demoMode}`, () => {
|
||||||
|
expect(entries.get('DEMO_MODE')).toBe(deployment.demoMode);
|
||||||
});
|
});
|
||||||
|
|
||||||
// The reason UPLOADS_DIR is hardcoded rather than taken from a stack
|
// The reason UPLOADS_DIR is hardcoded rather than taken from a stack
|
||||||
// variable is that it has to agree with the volume mapping. That claim is
|
// variable is that it has to agree with the volume mapping. That claim is
|
||||||
// only worth making if something checks it.
|
// only worth making if something checks it — and its absence from production
|
||||||
|
// is what stopped that stack booting.
|
||||||
it('points UPLOADS_DIR at the directory the uploads volume is mounted on', () => {
|
it('points UPLOADS_DIR at the directory the uploads volume is mounted on', () => {
|
||||||
const uploadsDir = entries.get('UPLOADS_DIR');
|
const uploadsDir = entries.get('UPLOADS_DIR');
|
||||||
expect(uploadsDir).toBeTruthy();
|
expect(uploadsDir).toBeTruthy();
|
||||||
@@ -79,4 +134,28 @@ describe('the QA compose file provides everything the app requires to boot', ()
|
|||||||
it('references ADMIN_GATE_SECRET from the stack rather than holding a value', () => {
|
it('references ADMIN_GATE_SECRET from the stack rather than holding a value', () => {
|
||||||
expect(entries.get('ADMIN_GATE_SECRET')).toBe('${ADMIN_GATE_SECRET}');
|
expect(entries.get('ADMIN_GATE_SECRET')).toBe('${ADMIN_GATE_SECRET}');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (deployment.requiresMailAllowlist) {
|
||||||
|
it('restricts delivery with MAIL_ALLOWLIST', () => {
|
||||||
|
expect(entries.get('MAIL_ALLOWLIST')).toBeTruthy();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('every deployment in the repository is under this guard', () => {
|
||||||
|
// The failure #118 was about was a file nothing read. A new environment added
|
||||||
|
// as a compose file and not registered above would reproduce it exactly, so
|
||||||
|
// the omission has to fail rather than pass quietly.
|
||||||
|
//
|
||||||
|
// Scoped to the repository root: backend/docker-compose.test.yml is a bare
|
||||||
|
// Postgres for the integration suite, with no app service and nothing here to
|
||||||
|
// say about it.
|
||||||
|
it('registers every root-level compose file in DEPLOYMENTS', () => {
|
||||||
|
const found = readdirSync(REPO_ROOT).filter(
|
||||||
|
(name) => /^docker-compose\..+\.ya?ml$/.test(name)
|
||||||
|
);
|
||||||
|
const registered = DEPLOYMENTS.map((d) => d.file);
|
||||||
|
|
||||||
|
expect(found.slice().sort()).toEqual(registered.slice().sort());
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
# Production stack — the live storefront. See issue #118 for why this file is
|
||||||
|
# in the repository at all.
|
||||||
|
#
|
||||||
|
# It used to live only in Portainer's web editor, which meant no test could read
|
||||||
|
# it. `backend/tests/unit/composeEnvironment.test.ts` asserts that the deploying
|
||||||
|
# environment sets every name in ALWAYS_REQUIRED, and it could only ever check
|
||||||
|
# QA. Production was unguarded, and on 2026-08-23 it refused to boot because
|
||||||
|
# UPLOADS_DIR had no line here — while being set in Portainer's stack variables,
|
||||||
|
# where it does nothing. Committing the file is what lets the guard cover it.
|
||||||
|
#
|
||||||
|
# Name the Portainer stack `redefined-designs`, NOT `redefined-designs-qa`.
|
||||||
|
# The stack name becomes the compose project name, and reusing QA's would make
|
||||||
|
# compose reconcile the two against each other.
|
||||||
|
#
|
||||||
|
# DEPLOY THIS AS A GIT REPOSITORY STACK, not from the web editor — otherwise the
|
||||||
|
# file here and the file that actually runs drift apart again, which is the
|
||||||
|
# whole problem this is solving.
|
||||||
|
#
|
||||||
|
# Repository: https://gitea.bermudalamb.synology.me/bermudalamb/redefined-designs
|
||||||
|
# Reference: refs/heads/main
|
||||||
|
# Compose path: docker-compose.prod.yml
|
||||||
|
#
|
||||||
|
# `pull_policy: build` matters. Without it the stack reuses whatever is already
|
||||||
|
# tagged redefined-designs:latest, which is how a redeploy can appear to succeed
|
||||||
|
# while still running old code. Leave any Portainer option that re-pulls images
|
||||||
|
# turned OFF — there is no registry to pull this image from.
|
||||||
|
#
|
||||||
|
# WHY MOST VALUES ARE HARDCODED HERE RATHER THAN INTERPOLATED
|
||||||
|
#
|
||||||
|
# Portainer's stack variables are substituted into this file; they are not
|
||||||
|
# handed to the container. A variable set in Portainer with no line here never
|
||||||
|
# reaches the app, and the failure reads as "I set it and it says it is not
|
||||||
|
# set". Only secrets are interpolated below, because only secrets have a reason
|
||||||
|
# not to be in the repository. Everything else is written out, so there is one
|
||||||
|
# place to look and one thing that can be wrong.
|
||||||
|
#
|
||||||
|
# Required stack environment variables — all secrets, all must be set in
|
||||||
|
# Portainer for this stack:
|
||||||
|
#
|
||||||
|
# DB_PASSWORD Postgres password for the `redefined` database.
|
||||||
|
# SMTP_USER Brevo SMTP login.
|
||||||
|
# SMTP_PASSWORD
|
||||||
|
# SMTP_FROM The From address customers see.
|
||||||
|
# ADMIN_GATE_SECRET The shared secret Nginx Proxy Manager injects as the
|
||||||
|
# X-Admin-Gate header on the gated location. Both sides
|
||||||
|
# must hold the same value or the admin API returns 403.
|
||||||
|
# See #63. Without it, /api/admin is protected only by
|
||||||
|
# the proxy — anything reaching the container directly
|
||||||
|
# can administer the store.
|
||||||
|
# PAYPAL_CLIENT_ID Live PayPal credentials. Required because DEMO_MODE is
|
||||||
|
# PAYPAL_CLIENT_SECRET false below; the app refuses to start without them.
|
||||||
|
# PAYPAL_WEBHOOK_ID
|
||||||
|
# USPS_CLIENT_ID Optional. Leave unset to run without address
|
||||||
|
# USPS_CLIENT_SECRET validation; the app degrades gracefully rather than
|
||||||
|
# failing, so an empty value is a working configuration.
|
||||||
|
#
|
||||||
|
# If the existing stack in Portainer uses different names for any of these,
|
||||||
|
# rename them there to match — the names above are what this file reads.
|
||||||
|
|
||||||
|
services:
|
||||||
|
redefined-designs:
|
||||||
|
# Built from this repository by Portainer rather than pulled. The same
|
||||||
|
# Dockerfile QA uses, so the two images differ only in configuration.
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: redefined-designs:latest
|
||||||
|
# Always build; never reuse the existing tag.
|
||||||
|
pull_policy: build
|
||||||
|
container_name: redefined-designs-syn
|
||||||
|
environment:
|
||||||
|
- TZ=America/Chicago
|
||||||
|
- PORT=3000
|
||||||
|
|
||||||
|
# NODE_ENV is deliberately absent. The Dockerfile sets it to `production`,
|
||||||
|
# and that value gates the `secure` flag on the session cookie. Setting it
|
||||||
|
# to anything else here would silently serve session cookies over plain
|
||||||
|
# HTTP. Do not add a line for it.
|
||||||
|
|
||||||
|
- PGHOST=redefined-designs-db-syn
|
||||||
|
- PGPORT=5432
|
||||||
|
- PGUSER=redefined
|
||||||
|
- PGPASSWORD=${DB_PASSWORD}
|
||||||
|
- PGDATABASE=redefined
|
||||||
|
|
||||||
|
# Real payments. This is the difference between production and QA, and it
|
||||||
|
# is why the three PayPal secrets are required rather than optional — the
|
||||||
|
# app refuses to start without them when this is false.
|
||||||
|
#
|
||||||
|
# To bring the stack up before PayPal is configured, set this to `true`
|
||||||
|
# and the three PAYPAL_ lines can be removed. The full cart and checkout
|
||||||
|
# flow then works end to end and NOBODY IS EVER CHARGED. That is a
|
||||||
|
# deliberate interim state and a quiet disaster if it is left on.
|
||||||
|
- DEMO_MODE=false
|
||||||
|
- PAYPAL_ENV=live
|
||||||
|
- PAYPAL_CLIENT_ID=${PAYPAL_CLIENT_ID}
|
||||||
|
- PAYPAL_CLIENT_SECRET=${PAYPAL_CLIENT_SECRET}
|
||||||
|
- PAYPAL_WEBHOOK_ID=${PAYPAL_WEBHOOK_ID}
|
||||||
|
|
||||||
|
# Host, port and secure are not secrets and are pinned rather than
|
||||||
|
# inherited: the mailer's fallbacks are Gmail's (smtp.gmail.com, 465, TLS)
|
||||||
|
# and Brevo needs 587 with STARTTLS, which is why SMTP_SECURE is false.
|
||||||
|
# Getting these wrong fails at send time, not at boot.
|
||||||
|
- SMTP_HOST=smtp-relay.brevo.com
|
||||||
|
- SMTP_PORT=587
|
||||||
|
- SMTP_SECURE=false
|
||||||
|
- SMTP_USER=${SMTP_USER}
|
||||||
|
- SMTP_PASSWORD=${SMTP_PASSWORD}
|
||||||
|
- SMTP_FROM=${SMTP_FROM}
|
||||||
|
|
||||||
|
# MAIL_ALLOWLIST is deliberately absent, and this is the one environment
|
||||||
|
# where that is correct. It restricts delivery to named recipients, which
|
||||||
|
# is what keeps QA from emailing real customers. Production has to be able
|
||||||
|
# to reach real customers, so it is unrestricted here on purpose. The
|
||||||
|
# boot-time warning about it is expected and should not be silenced.
|
||||||
|
|
||||||
|
# Not a secret, and hardcoded rather than interpolated so it cannot go
|
||||||
|
# missing: every link in a verification, password-reset, favorite-alert
|
||||||
|
# and cart-reminder email is built from it, and an unset value renders
|
||||||
|
# them all as "undefined".
|
||||||
|
- PUBLIC_URL=https://redefined-designs.bermudalamb.synology.me
|
||||||
|
|
||||||
|
- SITE_CURRENCY=USD
|
||||||
|
|
||||||
|
# Must match the right-hand side of the volume mapping below. Hardcoded
|
||||||
|
# for that reason — splitting it across two places is how they drift, and
|
||||||
|
# its absence is what stopped this stack booting on 2026-08-23.
|
||||||
|
- UPLOADS_DIR=/app/uploads
|
||||||
|
|
||||||
|
# Optional. Address validation is skipped when these are empty, rather
|
||||||
|
# than failing, so an unset pair is a working configuration.
|
||||||
|
- USPS_ENV=production
|
||||||
|
- USPS_CLIENT_ID=${USPS_CLIENT_ID}
|
||||||
|
- USPS_CLIENT_SECRET=${USPS_CLIENT_SECRET}
|
||||||
|
|
||||||
|
- ADMIN_GATE_SECRET=${ADMIN_GATE_SECRET}
|
||||||
|
volumes:
|
||||||
|
# Production's own uploads directory. QA writes to
|
||||||
|
# /volume1/configs/redefined-designs-qa/uploads; sharing this one would
|
||||||
|
# let a QA teardown delete real product images.
|
||||||
|
- /volume1/configs/redefined-designs/uploads:/app/uploads
|
||||||
|
ports:
|
||||||
|
# 32750, not QA's 32751.
|
||||||
|
#
|
||||||
|
# An unqualified host port binds to 0.0.0.0, so this answers directly on
|
||||||
|
# http://<nas-ip>:32750 from anywhere on the LAN, bypassing Nginx Proxy
|
||||||
|
# Manager and its TLS. The admin gate still fails closed — a direct
|
||||||
|
# request arrives without the X-Admin-Gate header — but the storefront,
|
||||||
|
# the customer API, login and registration are all reachable in the clear.
|
||||||
|
#
|
||||||
|
# That is #117, and the fix is not a loopback binding: NPM runs as its own
|
||||||
|
# container, so 127.0.0.1 would stop the proxy reaching this at all. The
|
||||||
|
# fix is a shared external network with this block removed entirely, which
|
||||||
|
# also requires repointing the proxy host entry at the container name and
|
||||||
|
# port 3000. Left as-is here so this file matches what is deployed today;
|
||||||
|
# changing it is #117's job, not this file's.
|
||||||
|
- 32750:3000
|
||||||
|
depends_on:
|
||||||
|
redefined-designs-db-syn:
|
||||||
|
condition: service_healthy
|
||||||
|
# Unlike QA's `no`: production is meant to come back after a NAS reboot.
|
||||||
|
restart: unless-stopped
|
||||||
|
# Docker's default json-file driver has no size cap. POST /api/client-errors
|
||||||
|
# is unauthenticated, so an unrotated log is a disk-filling vector on its
|
||||||
|
# own. QA has carried these options for a while; production could not,
|
||||||
|
# because this file did not exist.
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: 10m
|
||||||
|
max-file: "3"
|
||||||
|
|
||||||
|
redefined-designs-db-syn:
|
||||||
|
image: postgres:16
|
||||||
|
container_name: redefined-designs-db-syn
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=redefined
|
||||||
|
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||||
|
- POSTGRES_DB=redefined
|
||||||
|
- PGDATA=/var/lib/postgresql/data/pgdata
|
||||||
|
volumes:
|
||||||
|
# THE REAL DATA. Distinct from QA's
|
||||||
|
# /volume1/configs/redefined-designs-qa/postgres. Never point a QA stack
|
||||||
|
# at this path.
|
||||||
|
- /volume1/configs/redefined-designs/postgres:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U redefined -d redefined"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
restart: unless-stopped
|
||||||
|
logging:
|
||||||
|
driver: json-file
|
||||||
|
options:
|
||||||
|
max-size: 10m
|
||||||
|
max-file: "3"
|
||||||
Reference in New Issue
Block a user