Merge pull request 'fix(deploy): commit production's compose and bring it under the drift guard (#118)' (#145) from feature/118-production-compose into main
Linting / lint (push) Successful in 1m52s
SonarQube Analysis / sonarqube (push) Successful in 18m37s

Reviewed-on: #145
This commit was merged in pull request #145.
This commit is contained in:
2026-08-23 16:15:14 -05:00
2 changed files with 297 additions and 22 deletions
+100 -21
View File
@@ -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;
} }
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); 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());
});
}); });
+196
View File
@@ -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"