@zaaxch/tailframe 2.1.0 → 2.2.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/package.json +1 -1
- package/src/new.mjs +403 -37
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zaaxch/tailframe",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "Tailframe architecture toolkit: validates the Tailframe structure, import-boundary, and file-convention contracts. The package version is the contract version.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/new.mjs
CHANGED
|
@@ -63,6 +63,9 @@ if (fs.existsSync(root) && fs.readdirSync(root).length) fail(`Refusing to overwr
|
|
|
63
63
|
const title = options.name.split("-").map((part) => part[0].toUpperCase() + part.slice(1)).join(" ");
|
|
64
64
|
const svc = `${options.name}-svc`;
|
|
65
65
|
const ui = `${options.name}-ui`;
|
|
66
|
+
const databaseName = options.name.replaceAll("-", "_");
|
|
67
|
+
const mongoApplicationUser = `${options.name}-app`;
|
|
68
|
+
const mongoBackupUser = `${options.name}-backup`;
|
|
66
69
|
const files = {};
|
|
67
70
|
const add = (relative, content) => { files[relative] = content.endsWith("\n") ? content : `${content}\n`; };
|
|
68
71
|
// Generated repositories depend on the published toolkit rather than copying a validator, and they
|
|
@@ -222,11 +225,24 @@ module.exports = { projects: [
|
|
|
222
225
|
] };`);
|
|
223
226
|
add(`${svc}/.prettierrc.json`, JSON.stringify(prettierConfig, null, "\t"));
|
|
224
227
|
add(`${svc}/.gitattributes`, gitAttributes);
|
|
225
|
-
add(`${svc}/.gitignore`, `node_modules/\ndist/\n.env*\n!.env.example\n*.log\nfirebase-service-account*.json\n`);
|
|
228
|
+
add(`${svc}/.gitignore`, `node_modules/\ndist/\n.env*\n!.env.example\n!.env.infrastructure.example\n*.log\nfirebase-service-account*.json\n`);
|
|
226
229
|
add(`${svc}/.dockerignore`, `node_modules\ndist\n.git\n.env*\n!.env.example\n*.log\ndata\nfirebase-service-account*.json\n`);
|
|
227
230
|
if (options.ui) add(".dockerignore", `**/node_modules\n**/dist\n**/.git\n**/.env*\n**/*.log\n**/data\n**/firebase-service-account*.json\n`);
|
|
228
|
-
const dbEnv = "MONGODB_URI=mongodb://localhost:27017/?replicaSet=rs0&directConnection=true\nMONGODB_DB_NAME=" +
|
|
231
|
+
const dbEnv = "MONGODB_URI=mongodb://localhost:27017/?replicaSet=rs0&directConnection=true\nMONGODB_DB_NAME=" + databaseName;
|
|
229
232
|
add(`${svc}/.env.example`, `NODE_ENV=development\nPORT=3000\nCORS_ORIGIN=https://localhost:5173\n${dbEnv}\n${options.auth === "firebase" ? "FIREBASE_PROJECT_ID=\n" : ""}${options.redis ? "REDIS_URL=redis://localhost:6379\n" : ""}`);
|
|
233
|
+
add(`${svc}/.env.infrastructure.example`, `ECR_IMAGE=<account>.dkr.ecr.<region>.amazonaws.com/${options.name}
|
|
234
|
+
IMAGE_TAG=<service-sha>${options.ui ? "_<ui-sha>" : ""}
|
|
235
|
+
APP_ENV_FILE=/opt/${options.name}/shared/.env.production
|
|
236
|
+
MONGO_DATA_DIR=/opt/${options.name}/shared/mongo/data
|
|
237
|
+
MONGO_KEYFILE=/opt/${options.name}/shared/mongo/config/keyfile
|
|
238
|
+
MONGO_INIT_SCRIPT=/opt/${options.name}/current/deploy/mongo/10-create-users.js
|
|
239
|
+
MONGODB_ROOT_PASSWORD=<uri-safe-random-value>
|
|
240
|
+
MONGODB_APP_PASSWORD=<different-uri-safe-random-value>
|
|
241
|
+
MONGODB_BACKUP_PASSWORD=<different-uri-safe-random-value>
|
|
242
|
+
${options.auth === "firebase" ? `SECRETS_DIR=/opt/${options.name}/shared/secrets
|
|
243
|
+
` : ""}${options.redis ? `REDIS_DATA_DIR=/opt/${options.name}/shared/redis/data
|
|
244
|
+
REDIS_PASSWORD=<different-uri-safe-random-value>
|
|
245
|
+
` : ""}`);
|
|
230
246
|
add(`${svc}/src/platform/config/env.ts`, `import "@dotenvx/dotenvx/config";
|
|
231
247
|
export const env = {
|
|
232
248
|
nodeEnv: process.env.NODE_ENV ?? "development",
|
|
@@ -234,7 +250,7 @@ export const env = {
|
|
|
234
250
|
corsOrigin: process.env.CORS_ORIGIN ?? "https://localhost:5173",
|
|
235
251
|
${options.auth === "firebase" ? `firebaseProjectId: process.env.FIREBASE_PROJECT_ID ?? "",` : ""}
|
|
236
252
|
mongodbUri: process.env.MONGODB_URI ?? "mongodb://localhost:27017/?replicaSet=rs0&directConnection=true",
|
|
237
|
-
mongodbDbName: process.env.MONGODB_DB_NAME ?? "${
|
|
253
|
+
mongodbDbName: process.env.MONGODB_DB_NAME ?? "${databaseName}",
|
|
238
254
|
${options.redis ? `redisUrl: process.env.REDIS_URL ?? "redis://localhost:6379",` : ""}
|
|
239
255
|
};`);
|
|
240
256
|
const databaseSource = `import { MongoClient } from "mongodb";
|
|
@@ -444,10 +460,14 @@ This service uses MongoDB. Module-owned MongoDB adapters own collection access,
|
|
|
444
460
|
- Keep optional entry-point assembly under \`src/app/<entry-point-kind>\`; root files such as \`src/server.ts\` and \`src/worker.ts\` may only bootstrap that assembly.
|
|
445
461
|
- Initialize infrastructure first, then call \`registerDependencies(...)\` from every process entry point. The composition root resets the container, explicitly constructs dependencies, and registers class-token instances. Keep tsyringe, injection tokens, decorators, and container resolution out of modules and platform adapters.
|
|
446
462
|
- Register routes, workers, and jobs explicitly. Shutdown paths are awaitable and close resources without forcing \`process.exit\`.
|
|
463
|
+
- Scheduled work prevents unintended overlap, stops intake before shutdown, awaits the active run, destroys its scheduler or consumer, and only then closes Redis and database clients.
|
|
447
464
|
${options.ui ? "- In production, serve the compiled UI from `dist/public` with a non-API SPA fallback. Never return the SPA for `/api` requests." : ""}
|
|
448
465
|
|
|
449
466
|
## Production image
|
|
450
|
-
The ECR build targets \`linux/amd64\`. ${options.ui ? "Its default immutable tag is `<service-sha>_<ui-sha>` and production Compose requires that exact tag." : "Its default immutable tag is `<service-sha>` and production Compose requires that exact tag."}
|
|
467
|
+
The ECR build targets \`linux/amd64\`. ${options.ui ? "Its default immutable tag is `<service-sha>_<ui-sha>` and production Compose requires that exact tag." : "Its default immutable tag is `<service-sha>` and production Compose requires that exact tag."} The runtime image contains production dependencies only and runs as the Node user.${options.ui && options.auth === "firebase" ? ` The build requires \`${ui}/.env.production\` and mounts it as a BuildKit secret only while Vite compiles the browser bundle; it is not copied into the final image.` : ""}
|
|
468
|
+
|
|
469
|
+
## Production deployment
|
|
470
|
+
Only service \`main\` pushes or manual workflow dispatch deploy; UI pushes never trigger deployment. GitHub Actions may replace files under \`/opt/${options.name}/current\` and MUST NOT replace persistent configuration, credentials, keyfiles, or data under \`/opt/${options.name}/shared\`. Keep Node runtime behavior in the shared service \`.env.production\`, Compose interpolation and infrastructure credentials in \`.env.infrastructure\`, and fixed topology wiring in Compose \`environment:\`. The optional UI build-time \`.env.production\` is a separate ephemeral file. Do not run deployment until the host and GitHub production environment have been bootstrapped according to \`docs/production-deployment.md\`.
|
|
451
471
|
|
|
452
472
|
## Tests and validation
|
|
453
473
|
Run \`npm run validate:architecture\` and \`npm run format:check\` after changing files or imports. The generated service also includes unit tests and Mongo-backed integration tests. Run \`npm run test:db:up\`, \`npm test\`, and \`npm run test:db:down\` for the full service check. Test use cases, policies, Mongo repository adapters, and HTTP boundaries where behavior lives. Every public operation needs success and error-envelope coverage; authenticated operations need trusted-identity coverage; persisted capabilities need Mongo ownership/query coverage; UI-enabled services must verify that \`/api\` paths never fall through to the SPA. All AWS/ECR and Docker commands, including \`npm run docker:push\`, must run outside the sandbox from the first attempt.
|
|
@@ -610,6 +630,11 @@ RUN npm ci
|
|
|
610
630
|
COPY ${svc}/ ./
|
|
611
631
|
RUN npm run build
|
|
612
632
|
|
|
633
|
+
FROM node:22.13-bookworm AS service-production-dependencies
|
|
634
|
+
WORKDIR /app/service
|
|
635
|
+
COPY ${svc}/package*.json ./
|
|
636
|
+
RUN npm ci --omit=dev && npm cache clean --force
|
|
637
|
+
|
|
613
638
|
FROM node:22.13-bookworm AS ui-build
|
|
614
639
|
WORKDIR /app/ui
|
|
615
640
|
COPY ${ui}/package*.json ./
|
|
@@ -622,8 +647,9 @@ WORKDIR /app
|
|
|
622
647
|
ENV NODE_ENV=production
|
|
623
648
|
COPY --from=service-build /app/service/dist ./dist
|
|
624
649
|
COPY --from=service-build /app/service/package*.json ./
|
|
625
|
-
COPY --from=service-
|
|
650
|
+
COPY --from=service-production-dependencies /app/service/node_modules ./node_modules
|
|
626
651
|
COPY --from=ui-build /app/ui/dist ./dist/public
|
|
652
|
+
USER node
|
|
627
653
|
EXPOSE 3000
|
|
628
654
|
CMD ["node", "dist/server.js"]
|
|
629
655
|
` : `FROM node:22.13-bookworm AS build
|
|
@@ -633,12 +659,18 @@ RUN npm ci
|
|
|
633
659
|
COPY . .
|
|
634
660
|
RUN npm run build
|
|
635
661
|
|
|
662
|
+
FROM node:22.13-bookworm AS production-dependencies
|
|
663
|
+
WORKDIR /app
|
|
664
|
+
COPY package*.json ./
|
|
665
|
+
RUN npm ci --omit=dev && npm cache clean --force
|
|
666
|
+
|
|
636
667
|
FROM node:22.13-bookworm-slim
|
|
637
668
|
WORKDIR /app
|
|
638
669
|
ENV NODE_ENV=production
|
|
639
670
|
COPY --from=build /app/dist ./dist
|
|
640
671
|
COPY --from=build /app/package*.json ./
|
|
641
|
-
COPY --from=
|
|
672
|
+
COPY --from=production-dependencies /app/node_modules ./node_modules
|
|
673
|
+
USER node
|
|
642
674
|
EXPOSE 3000
|
|
643
675
|
CMD ["node", "dist/server.js"]
|
|
644
676
|
`);
|
|
@@ -678,75 +710,409 @@ docker push "\${IMAGE}"
|
|
|
678
710
|
echo "Pushed \${IMAGE}"
|
|
679
711
|
`);
|
|
680
712
|
|
|
713
|
+
const workflowUiCheckout = options.ui ? `
|
|
714
|
+
- name: Checkout UI dependency
|
|
715
|
+
uses: actions/checkout@v6
|
|
716
|
+
with:
|
|
717
|
+
repository: \${{ github.repository_owner }}/${ui}
|
|
718
|
+
ref: main
|
|
719
|
+
ssh-key: \${{ secrets.UI_REPO_SSH_KEY }}
|
|
720
|
+
path: ${ui}
|
|
721
|
+
` : "";
|
|
722
|
+
const workflowUiEnvironment = options.ui && options.auth === "firebase" ? `
|
|
723
|
+
- name: Write UI production environment
|
|
724
|
+
env:
|
|
725
|
+
UI_ENV_PRODUCTION: \${{ secrets.UI_ENV_PRODUCTION }}
|
|
726
|
+
run: |
|
|
727
|
+
printf '%s\\n' "$UI_ENV_PRODUCTION" > ${ui}/.env.production
|
|
728
|
+
` : "";
|
|
729
|
+
const workflowTagResolution = options.ui ? `
|
|
730
|
+
ui_sha="$(git -C ${ui} rev-parse --short HEAD)"
|
|
731
|
+
echo "tag=\${svc_sha}_\${ui_sha}" >> "$GITHUB_OUTPUT"` : `
|
|
732
|
+
echo "tag=\${svc_sha}" >> "$GITHUB_OUTPUT"`;
|
|
733
|
+
add(`${svc}/.github/workflows/deploy.yml`, `name: Build and deploy
|
|
734
|
+
|
|
735
|
+
on:
|
|
736
|
+
push:
|
|
737
|
+
branches:
|
|
738
|
+
- main
|
|
739
|
+
workflow_dispatch:
|
|
740
|
+
|
|
741
|
+
permissions:
|
|
742
|
+
contents: read
|
|
743
|
+
id-token: write
|
|
744
|
+
|
|
745
|
+
concurrency:
|
|
746
|
+
group: ${options.name}-production
|
|
747
|
+
cancel-in-progress: false
|
|
748
|
+
|
|
749
|
+
jobs:
|
|
750
|
+
deploy:
|
|
751
|
+
name: Deploy production
|
|
752
|
+
if: \${{ vars.DEPLOY_HOST != '' && vars.DEPLOY_USER != '' }}
|
|
753
|
+
runs-on: ubuntu-latest
|
|
754
|
+
environment: production
|
|
755
|
+
env:
|
|
756
|
+
AWS_ACCOUNT_ID: \${{ vars.AWS_ACCOUNT_ID }}
|
|
757
|
+
AWS_REGION: \${{ vars.AWS_REGION }}
|
|
758
|
+
ECR_REPOSITORY: \${{ vars.ECR_REPOSITORY || '${options.name}' }}
|
|
759
|
+
DEPLOY_HOST: \${{ vars.DEPLOY_HOST }}
|
|
760
|
+
DEPLOY_USER: \${{ vars.DEPLOY_USER }}
|
|
761
|
+
|
|
762
|
+
steps:
|
|
763
|
+
- name: Checkout service
|
|
764
|
+
uses: actions/checkout@v6
|
|
765
|
+
with:
|
|
766
|
+
path: ${svc}
|
|
767
|
+
${workflowUiCheckout}${workflowUiEnvironment}
|
|
768
|
+
- name: Resolve immutable image
|
|
769
|
+
id: image
|
|
770
|
+
run: |
|
|
771
|
+
svc_sha="$(git -C ${svc} rev-parse --short HEAD)"${workflowTagResolution}
|
|
772
|
+
echo "registry=\${AWS_ACCOUNT_ID}.dkr.ecr.\${AWS_REGION}.amazonaws.com" >> "$GITHUB_OUTPUT"
|
|
773
|
+
echo "image=\${AWS_ACCOUNT_ID}.dkr.ecr.\${AWS_REGION}.amazonaws.com/\${ECR_REPOSITORY}" >> "$GITHUB_OUTPUT"
|
|
774
|
+
|
|
775
|
+
- name: Configure AWS credentials
|
|
776
|
+
uses: aws-actions/configure-aws-credentials@v6
|
|
777
|
+
with:
|
|
778
|
+
role-to-assume: \${{ vars.AWS_ROLE_ARN }}
|
|
779
|
+
aws-region: \${{ vars.AWS_REGION }}
|
|
780
|
+
|
|
781
|
+
- name: Build and push image
|
|
782
|
+
working-directory: ${svc}
|
|
783
|
+
env:
|
|
784
|
+
IMAGE_TAG: \${{ steps.image.outputs.tag }}
|
|
785
|
+
run: ./scripts/build_and_push.sh
|
|
786
|
+
|
|
787
|
+
- name: Configure deployment SSH
|
|
788
|
+
env:
|
|
789
|
+
DEPLOY_SSH_PRIVATE_KEY: \${{ secrets.DEPLOY_SSH_PRIVATE_KEY }}
|
|
790
|
+
DEPLOY_SSH_KNOWN_HOSTS: \${{ secrets.DEPLOY_SSH_KNOWN_HOSTS }}
|
|
791
|
+
run: |
|
|
792
|
+
install -m 700 -d ~/.ssh
|
|
793
|
+
printf '%s\\n' "$DEPLOY_SSH_PRIVATE_KEY" > ~/.ssh/${options.name}-deploy
|
|
794
|
+
chmod 600 ~/.ssh/${options.name}-deploy
|
|
795
|
+
printf '%s\\n' "$DEPLOY_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
|
|
796
|
+
|
|
797
|
+
- name: Authenticate server with ECR
|
|
798
|
+
run: |
|
|
799
|
+
aws ecr get-login-password --region "$AWS_REGION" |
|
|
800
|
+
ssh -i ~/.ssh/${options.name}-deploy "\${DEPLOY_USER}@\${DEPLOY_HOST}" \\
|
|
801
|
+
"docker login --username AWS --password-stdin '\${{ steps.image.outputs.registry }}'"
|
|
802
|
+
|
|
803
|
+
- name: Install deployment files
|
|
804
|
+
run: |
|
|
805
|
+
ssh -i ~/.ssh/${options.name}-deploy "\${DEPLOY_USER}@\${DEPLOY_HOST}" \\
|
|
806
|
+
"install -m 755 -d /opt/${options.name}/current/deploy/mongo"
|
|
807
|
+
scp -i ~/.ssh/${options.name}-deploy \\
|
|
808
|
+
${svc}/docker-compose.yml \\
|
|
809
|
+
${svc}/scripts/deploy_remote.sh \\
|
|
810
|
+
"\${DEPLOY_USER}@\${DEPLOY_HOST}:/opt/${options.name}/current/"
|
|
811
|
+
scp -i ~/.ssh/${options.name}-deploy \\
|
|
812
|
+
${svc}/deploy/mongo/10-create-users.js \\
|
|
813
|
+
"\${DEPLOY_USER}@\${DEPLOY_HOST}:/opt/${options.name}/current/deploy/mongo/"
|
|
814
|
+
ssh -i ~/.ssh/${options.name}-deploy "\${DEPLOY_USER}@\${DEPLOY_HOST}" \\
|
|
815
|
+
"chmod 755 /opt/${options.name}/current/deploy_remote.sh"
|
|
816
|
+
|
|
817
|
+
- name: Deploy image
|
|
818
|
+
run: |
|
|
819
|
+
ssh -i ~/.ssh/${options.name}-deploy "\${DEPLOY_USER}@\${DEPLOY_HOST}" \\
|
|
820
|
+
"/opt/${options.name}/current/deploy_remote.sh '\${{ steps.image.outputs.tag }}' '\${{ steps.image.outputs.image }}'"
|
|
821
|
+
`);
|
|
822
|
+
|
|
823
|
+
add(`${svc}/scripts/deploy_remote.sh`, `#!/usr/bin/env bash
|
|
824
|
+
set -euo pipefail
|
|
825
|
+
|
|
826
|
+
IMAGE_TAG="\${1:?Usage: deploy_remote.sh IMAGE_TAG ECR_IMAGE}"
|
|
827
|
+
ECR_IMAGE="\${2:?Usage: deploy_remote.sh IMAGE_TAG ECR_IMAGE}"
|
|
828
|
+
DEPLOY_DIR="\${DEPLOY_DIR:-/opt/${options.name}/current}"
|
|
829
|
+
SHARED_DIR="\${SHARED_DIR:-/opt/${options.name}/shared}"
|
|
830
|
+
COMPOSE_FILE="\${DEPLOY_DIR}/docker-compose.yml"
|
|
831
|
+
INFRA_ENV="\${SHARED_DIR}/.env.infrastructure"
|
|
832
|
+
|
|
833
|
+
if [[ ! -f "\${INFRA_ENV}" ]]; then
|
|
834
|
+
echo "Missing provisioned infrastructure environment: \${INFRA_ENV}" >&2
|
|
835
|
+
exit 1
|
|
836
|
+
fi
|
|
837
|
+
|
|
838
|
+
NEXT_ENV="$(mktemp "\${SHARED_DIR}/.env.infrastructure.XXXXXX")"
|
|
839
|
+
cleanup() {
|
|
840
|
+
rm -f "\${NEXT_ENV}"
|
|
841
|
+
}
|
|
842
|
+
trap cleanup EXIT
|
|
843
|
+
|
|
844
|
+
grep -Ev '^(ECR_IMAGE|IMAGE_TAG)=' "\${INFRA_ENV}" > "\${NEXT_ENV}" || true
|
|
845
|
+
printf 'ECR_IMAGE=%s\\nIMAGE_TAG=%s\\n' "\${ECR_IMAGE}" "\${IMAGE_TAG}" >> "\${NEXT_ENV}"
|
|
846
|
+
chmod 600 "\${NEXT_ENV}"
|
|
847
|
+
mv "\${NEXT_ENV}" "\${INFRA_ENV}"
|
|
848
|
+
trap - EXIT
|
|
849
|
+
|
|
850
|
+
docker compose --env-file "\${INFRA_ENV}" --file "\${COMPOSE_FILE}" config --quiet
|
|
851
|
+
docker compose --env-file "\${INFRA_ENV}" --file "\${COMPOSE_FILE}" pull
|
|
852
|
+
docker compose --env-file "\${INFRA_ENV}" --file "\${COMPOSE_FILE}" up -d --wait
|
|
853
|
+
`);
|
|
854
|
+
|
|
855
|
+
add(`${svc}/deploy/mongo/10-create-users.js`, `const applicationPassword = process.env.MONGODB_APP_PASSWORD;
|
|
856
|
+
const backupPassword = process.env.MONGODB_BACKUP_PASSWORD;
|
|
857
|
+
|
|
858
|
+
if (!applicationPassword || !backupPassword) {
|
|
859
|
+
throw new Error("MONGODB_APP_PASSWORD and MONGODB_BACKUP_PASSWORD are required");
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
db.getSiblingDB("${databaseName}").createUser({
|
|
863
|
+
user: "${mongoApplicationUser}",
|
|
864
|
+
pwd: applicationPassword,
|
|
865
|
+
roles: [{ role: "readWrite", db: "${databaseName}" }]
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
db.getSiblingDB("admin").createUser({
|
|
869
|
+
user: "${mongoBackupUser}",
|
|
870
|
+
pwd: backupPassword,
|
|
871
|
+
roles: [{ role: "backup", db: "admin" }]
|
|
872
|
+
});
|
|
873
|
+
`);
|
|
874
|
+
|
|
875
|
+
add(`${svc}/docs/production-deployment.md`, `# Production deployment
|
|
876
|
+
|
|
877
|
+
## Delivery model
|
|
878
|
+
|
|
879
|
+
GitHub Actions checks out ${svc}${options.ui ? ` and ${ui}` : ""}${options.ui ? " as sibling build inputs" : ""}, builds one
|
|
880
|
+
non-root production image, tags it ${options.ui ? "`<service-sha>_<ui-sha>`" : "`<service-sha>`"}, pushes it to ECR, and installs only deployment artifacts on the host.
|
|
881
|
+
Only service \`main\` pushes and manual dispatch trigger this workflow; UI pushes never deploy.
|
|
882
|
+
|
|
883
|
+
The host separates replaceable artifacts from persistent state:
|
|
884
|
+
|
|
885
|
+
\`\`\`text
|
|
886
|
+
/opt/${options.name}/
|
|
887
|
+
├── current/
|
|
888
|
+
│ ├── docker-compose.yml
|
|
889
|
+
│ ├── deploy_remote.sh
|
|
890
|
+
│ └── deploy/mongo/10-create-users.js
|
|
891
|
+
└── shared/
|
|
892
|
+
├── .env.production
|
|
893
|
+
├── .env.infrastructure
|
|
894
|
+
${options.auth === "firebase" ? ` ├── secrets/firebase-service-account.json
|
|
895
|
+
` : ""} ├── mongo/config/keyfile
|
|
896
|
+
├── mongo/data/
|
|
897
|
+
${options.redis ? ` └── redis/data/
|
|
898
|
+
` : ""}\`\`\`
|
|
899
|
+
|
|
900
|
+
Actions may replace \`current/\` and must never replace \`shared/\`. The server never needs a source checkout.
|
|
901
|
+
|
|
902
|
+
## Configuration ownership
|
|
903
|
+
|
|
904
|
+
- \`.env.production\` contains Node application behavior and provider credentials.
|
|
905
|
+
- \`.env.infrastructure\` contains Compose paths, image identity, and infrastructure passwords. Start from
|
|
906
|
+
\`.env.infrastructure.example\` and replace every placeholder.
|
|
907
|
+
- Compose \`environment:\` owns fixed production wiring: \`NODE_ENV\`, internal database/cache URLs, and mounted
|
|
908
|
+
credential paths.
|
|
909
|
+
${options.ui && options.auth === "firebase" ? `- The UI repository's build-time \`.env.production\` comes from the GitHub \`UI_ENV_PRODUCTION\` secret and is unrelated to the service runtime file above.
|
|
910
|
+
` : ""}
|
|
911
|
+
Do not inject \`.env.infrastructure\` into Node. Make both host environment files root-owned and mode \`0600\`.
|
|
912
|
+
|
|
913
|
+
## One-time host bootstrap
|
|
914
|
+
|
|
915
|
+
1. Install Docker Engine and Docker Compose.
|
|
916
|
+
2. Create \`/opt/${options.name}/current/deploy/mongo\`, \`/opt/${options.name}/shared/mongo/config\`, and
|
|
917
|
+
\`/opt/${options.name}/shared/mongo/data\`${options.redis ? `, plus \`/opt/${options.name}/shared/redis/data\`` : ""}.
|
|
918
|
+
3. Install \`.env.production\` and a completed \`.env.infrastructure\` under \`shared/\`.
|
|
919
|
+
4. Generate the MongoDB replica key with \`openssl rand -base64 756\`, make it owned by UID/GID \`999:999\`, and
|
|
920
|
+
mode \`0400\`.
|
|
921
|
+
${options.auth === "firebase" ? `5. Install the Firebase service account at \`shared/secrets/firebase-service-account.json\`, owned by \`root:1000\` and mode \`0640\`.
|
|
922
|
+
6. ` : "5. "}Give the deployment user Docker access and write access to \`current/\`; keep \`shared/\` and its files protected.
|
|
923
|
+
${options.auth === "firebase" ? "7" : "6"}. Authorize the dedicated deployment SSH key and record the host key for GitHub Actions.
|
|
924
|
+
|
|
925
|
+
Use a fresh Mongo data directory. Migrating an existing unauthenticated database requires a separately reviewed plan.
|
|
926
|
+
|
|
927
|
+
## GitHub production environment
|
|
928
|
+
|
|
929
|
+
Configure these variables:
|
|
930
|
+
|
|
931
|
+
- \`AWS_ACCOUNT_ID\`, \`AWS_REGION\`, and \`AWS_ROLE_ARN\` for the repository-restricted OIDC role.
|
|
932
|
+
- \`DEPLOY_HOST\` and \`DEPLOY_USER\`; the deployment job remains skipped until both exist.
|
|
933
|
+
- Optional \`ECR_REPOSITORY\`; the default is \`${options.name}\`.
|
|
934
|
+
|
|
935
|
+
Configure these secrets:
|
|
936
|
+
|
|
937
|
+
${options.ui ? `- \`UI_REPO_SSH_KEY\`: read-only deploy key for \`${ui}\`.
|
|
938
|
+
` : ""}${options.ui && options.auth === "firebase" ? `- \`UI_ENV_PRODUCTION\`: Vite/Firebase build-time environment.
|
|
939
|
+
` : ""}- \`DEPLOY_SSH_PRIVATE_KEY\`: dedicated host deployment key.
|
|
940
|
+
- \`DEPLOY_SSH_KNOWN_HOSTS\`: pinned host key.
|
|
941
|
+
|
|
942
|
+
The ECR repository and AWS OIDC provider/role are provisioned separately. The workflow never stores long-lived AWS
|
|
943
|
+
keys or creates cloud infrastructure.
|
|
944
|
+
|
|
945
|
+
## Deploy and rollback
|
|
946
|
+
|
|
947
|
+
The remote script requires the provisioned infrastructure file, atomically replaces only \`ECR_IMAGE\` and
|
|
948
|
+
\`IMAGE_TAG\`, validates Compose, pulls, and starts with health waiting. Roll back by selecting a previous immutable
|
|
949
|
+
tag and running the same script. Never use \`docker compose down -v\` in production; image rollback does not restore
|
|
950
|
+
database state.
|
|
951
|
+
|
|
952
|
+
MongoDB and optional Redis expose no host ports and require authentication. Verify API readiness, container health,
|
|
953
|
+
database/cache authentication, file permissions, and off-host backup/restore before treating the deployment as
|
|
954
|
+
production-ready. Product-specific TLS, capacity limits, migrations, and scheduled-work grace periods must be added
|
|
955
|
+
from measured application requirements rather than copied from another product.
|
|
956
|
+
`);
|
|
957
|
+
|
|
681
958
|
const serviceVolumes = [
|
|
682
|
-
...(options.auth === "firebase" ? [" -
|
|
959
|
+
...(options.auth === "firebase" ? [" - ${SECRETS_DIR:?Set SECRETS_DIR}/firebase-service-account.json:/app/secrets/firebase-service-account.json:ro"] : [])
|
|
683
960
|
];
|
|
684
961
|
const devVolumes = [
|
|
685
962
|
" - .:/app",
|
|
686
963
|
" - /app/node_modules",
|
|
687
964
|
...(options.auth === "firebase" ? [" - ./firebase-service-account.development.json:/app/firebase-service-account.development.json:ro"] : [])
|
|
688
965
|
];
|
|
689
|
-
const
|
|
966
|
+
const productionDependencies = [
|
|
967
|
+
["mongo-init", "service_completed_successfully"],
|
|
968
|
+
...(options.redis ? [["redis", "service_healthy"]] : [])
|
|
969
|
+
];
|
|
970
|
+
const developmentDependencies = [
|
|
690
971
|
["mongo-init", "service_completed_successfully"],
|
|
691
972
|
...(options.redis ? [["redis", "service_started"]] : [])
|
|
692
973
|
];
|
|
693
|
-
const dependsBlock = `\n depends_on:\n${
|
|
974
|
+
const dependsBlock = `\n depends_on:\n${productionDependencies.map(([dependency, condition]) => ` ${dependency}:\n condition: ${condition}`).join("\n")}`;
|
|
975
|
+
const devDependsBlock = `\n depends_on:\n${developmentDependencies.map(([dependency, condition]) => ` ${dependency}:\n condition: ${condition}`).join("\n")}`;
|
|
976
|
+
const productionEnvironment = [
|
|
977
|
+
" NODE_ENV: production",
|
|
978
|
+
` MONGODB_URI: "mongodb://${mongoApplicationUser}:\${MONGODB_APP_PASSWORD:?Set MONGODB_APP_PASSWORD}@mongodb:27017/${databaseName}?replicaSet=rs0&directConnection=true&authSource=${databaseName}"`,
|
|
979
|
+
` MONGODB_DB_NAME: "${databaseName}"`,
|
|
980
|
+
...(options.auth === "firebase" ? [" GOOGLE_APPLICATION_CREDENTIALS: /app/secrets/firebase-service-account.json"] : []),
|
|
981
|
+
...(options.redis ? [" REDIS_URL: \"redis://:\${REDIS_PASSWORD:?Set REDIS_PASSWORD}@redis:6379\""] : [])
|
|
982
|
+
];
|
|
983
|
+
const productionEnvironmentBlock = `\n environment:\n${productionEnvironment.join("\n")}`;
|
|
694
984
|
const databaseEnvironmentBlock = `\n environment:\n MONGODB_URI: "mongodb://mongodb:27017/?replicaSet=rs0&directConnection=true"`;
|
|
695
985
|
const volumeBlock = serviceVolumes.length ? `\n volumes:\n${serviceVolumes.join("\n")}` : "";
|
|
696
986
|
const workerService = options.worker ? `\n worker:
|
|
697
987
|
image: \${ECR_IMAGE:?Set ECR_IMAGE}:\${IMAGE_TAG:?Set IMAGE_TAG}
|
|
698
988
|
env_file:
|
|
699
|
-
-
|
|
700
|
-
command: ["node", "dist/worker.js"]${
|
|
989
|
+
- \${APP_ENV_FILE:-.env.production}
|
|
990
|
+
command: ["node", "dist/worker.js"]${productionEnvironmentBlock}${volumeBlock}${dependsBlock}
|
|
991
|
+
healthcheck:
|
|
992
|
+
test: ["CMD", "node", "-e", "process.kill(1, 0)"]
|
|
993
|
+
interval: 60s
|
|
994
|
+
timeout: 5s
|
|
995
|
+
retries: 3
|
|
996
|
+
start_period: 30s
|
|
997
|
+
stop_grace_period: 2m
|
|
998
|
+
networks:
|
|
999
|
+
- backend
|
|
701
1000
|
restart: unless-stopped` : "";
|
|
702
1001
|
const databaseService = `\n mongodb:
|
|
703
1002
|
image: mongo:8
|
|
704
|
-
command:
|
|
1003
|
+
command:
|
|
1004
|
+
- mongod
|
|
1005
|
+
- --replSet
|
|
1006
|
+
- rs0
|
|
1007
|
+
- --bind_ip_all
|
|
1008
|
+
- --keyFile
|
|
1009
|
+
- /etc/mongo-keyfile/keyfile
|
|
1010
|
+
environment:
|
|
1011
|
+
MONGO_INITDB_DATABASE: "${databaseName}"
|
|
1012
|
+
MONGO_INITDB_ROOT_USERNAME: "${options.name}-root"
|
|
1013
|
+
MONGO_INITDB_ROOT_PASSWORD: \${MONGODB_ROOT_PASSWORD:?Set MONGODB_ROOT_PASSWORD}
|
|
1014
|
+
MONGODB_APP_PASSWORD: \${MONGODB_APP_PASSWORD:?Set MONGODB_APP_PASSWORD}
|
|
1015
|
+
MONGODB_BACKUP_PASSWORD: \${MONGODB_BACKUP_PASSWORD:?Set MONGODB_BACKUP_PASSWORD}
|
|
705
1016
|
volumes:
|
|
706
|
-
-
|
|
1017
|
+
- \${MONGO_DATA_DIR:?Set MONGO_DATA_DIR}:/data/db
|
|
1018
|
+
- \${MONGO_KEYFILE:?Set MONGO_KEYFILE}:/etc/mongo-keyfile/keyfile:ro
|
|
1019
|
+
- \${MONGO_INIT_SCRIPT:?Set MONGO_INIT_SCRIPT}:/docker-entrypoint-initdb.d/10-create-users.js:ro
|
|
707
1020
|
healthcheck:
|
|
708
|
-
test:
|
|
709
|
-
|
|
710
|
-
|
|
1021
|
+
test:
|
|
1022
|
+
- CMD-SHELL
|
|
1023
|
+
- >-
|
|
1024
|
+
mongosh --quiet --host localhost
|
|
1025
|
+
--username "$\${MONGO_INITDB_ROOT_USERNAME}"
|
|
1026
|
+
--password "$\${MONGO_INITDB_ROOT_PASSWORD}"
|
|
1027
|
+
--authenticationDatabase admin
|
|
1028
|
+
--eval "quit(db.adminCommand({ping:1}).ok ? 0 : 1)"
|
|
1029
|
+
interval: 5s
|
|
1030
|
+
timeout: 5s
|
|
711
1031
|
retries: 30
|
|
1032
|
+
start_period: 20s
|
|
1033
|
+
networks:
|
|
1034
|
+
- backend
|
|
712
1035
|
restart: unless-stopped
|
|
713
1036
|
mongo-init:
|
|
714
1037
|
image: mongo:8
|
|
1038
|
+
environment:
|
|
1039
|
+
MONGO_INITDB_ROOT_USERNAME: "${options.name}-root"
|
|
1040
|
+
MONGO_INITDB_ROOT_PASSWORD: \${MONGODB_ROOT_PASSWORD:?Set MONGODB_ROOT_PASSWORD}
|
|
715
1041
|
depends_on:
|
|
716
1042
|
mongodb:
|
|
717
1043
|
condition: service_healthy
|
|
718
1044
|
restart: "no"
|
|
719
|
-
entrypoint:
|
|
720
|
-
|
|
721
|
-
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
1045
|
+
entrypoint: ["bash", "-ec"]
|
|
1046
|
+
command:
|
|
1047
|
+
- >-
|
|
1048
|
+
mongosh --quiet --host mongodb:27017
|
|
1049
|
+
--username "$\${MONGO_INITDB_ROOT_USERNAME}"
|
|
1050
|
+
--password "$\${MONGO_INITDB_ROOT_PASSWORD}"
|
|
1051
|
+
--authenticationDatabase admin
|
|
1052
|
+
--eval "try { const status = rs.status(); quit(status.ok === 1 ? 0 : 1); }
|
|
1053
|
+
catch (error) {
|
|
1054
|
+
if (error.code === 94 || error.codeName === 'NotYetInitialized') {
|
|
1055
|
+
const result = rs.initiate({_id:'rs0',members:[{_id:0,host:'mongodb:27017'}]});
|
|
1056
|
+
quit(result.ok === 1 ? 0 : 1);
|
|
728
1057
|
}
|
|
729
|
-
|
|
1058
|
+
throw error;
|
|
1059
|
+
}"
|
|
1060
|
+
networks:
|
|
1061
|
+
- backend`;
|
|
730
1062
|
const redisService = options.redis ? `\n redis:
|
|
731
1063
|
image: redis:7-alpine
|
|
1064
|
+
command:
|
|
1065
|
+
- redis-server
|
|
1066
|
+
- --appendonly
|
|
1067
|
+
- "yes"
|
|
1068
|
+
- --appendfsync
|
|
1069
|
+
- everysec
|
|
1070
|
+
- --maxmemory-policy
|
|
1071
|
+
- noeviction
|
|
1072
|
+
- --requirepass
|
|
1073
|
+
- \${REDIS_PASSWORD:?Set REDIS_PASSWORD}
|
|
1074
|
+
environment:
|
|
1075
|
+
REDIS_PASSWORD: \${REDIS_PASSWORD:?Set REDIS_PASSWORD}
|
|
732
1076
|
volumes:
|
|
733
|
-
-
|
|
1077
|
+
- \${REDIS_DATA_DIR:?Set REDIS_DATA_DIR}:/data
|
|
1078
|
+
healthcheck:
|
|
1079
|
+
test:
|
|
1080
|
+
- CMD-SHELL
|
|
1081
|
+
- REDISCLI_AUTH="$\${REDIS_PASSWORD}" redis-cli ping | grep -q PONG
|
|
1082
|
+
interval: 10s
|
|
1083
|
+
timeout: 3s
|
|
1084
|
+
retries: 10
|
|
1085
|
+
start_period: 10s
|
|
1086
|
+
networks:
|
|
1087
|
+
- backend
|
|
734
1088
|
restart: unless-stopped` : "";
|
|
735
|
-
const namedVolumes = [
|
|
736
|
-
" mongodb-data:",
|
|
737
|
-
...(options.redis ? [" redis-data:"] : [])
|
|
738
|
-
];
|
|
739
1089
|
add(`${svc}/docker-compose.yml`, `name: ${options.name}-prod
|
|
740
1090
|
services:
|
|
741
1091
|
${options.name}:
|
|
742
1092
|
image: \${ECR_IMAGE:?Set ECR_IMAGE}:\${IMAGE_TAG:?Set IMAGE_TAG}
|
|
743
1093
|
env_file:
|
|
744
|
-
-
|
|
1094
|
+
- \${APP_ENV_FILE:-.env.production}
|
|
745
1095
|
ports:
|
|
746
|
-
- "3000:3000"${
|
|
1096
|
+
- "3000:3000"${productionEnvironmentBlock}${volumeBlock}${dependsBlock}
|
|
1097
|
+
healthcheck:
|
|
1098
|
+
test:
|
|
1099
|
+
- CMD-SHELL
|
|
1100
|
+
- >-
|
|
1101
|
+
node -e
|
|
1102
|
+
"fetch('http://127.0.0.1:3000/api/v1/health.readiness',
|
|
1103
|
+
{method:'POST',headers:{'Content-Type':'application/json','X-Requested-With':'XMLHttpRequest'},body:'{}'})
|
|
1104
|
+
.then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
|
1105
|
+
interval: 30s
|
|
1106
|
+
timeout: 10s
|
|
1107
|
+
retries: 3
|
|
1108
|
+
start_period: 30s
|
|
1109
|
+
stop_grace_period: 2m
|
|
1110
|
+
networks:
|
|
1111
|
+
- backend
|
|
747
1112
|
restart: unless-stopped${workerService}${databaseService}${redisService}
|
|
748
|
-
|
|
749
|
-
|
|
1113
|
+
networks:
|
|
1114
|
+
backend:
|
|
1115
|
+
driver: bridge
|
|
750
1116
|
`);
|
|
751
1117
|
|
|
752
1118
|
const devWorker = options.worker ? `\n worker:
|
|
@@ -757,7 +1123,7 @@ const devWorker = options.worker ? `\n worker:
|
|
|
757
1123
|
- .env.development
|
|
758
1124
|
command: ["npm", "run", "dev:worker"]${databaseEnvironmentBlock}
|
|
759
1125
|
volumes:
|
|
760
|
-
${devVolumes.join("\n")}${
|
|
1126
|
+
${devVolumes.join("\n")}${devDependsBlock}` : "";
|
|
761
1127
|
const devDatabase = `\n mongodb:
|
|
762
1128
|
image: mongo:8
|
|
763
1129
|
command: mongod --replSet rs0 --bind_ip_all
|
|
@@ -808,7 +1174,7 @@ services:
|
|
|
808
1174
|
ports:
|
|
809
1175
|
- "3000:3000"${databaseEnvironmentBlock}
|
|
810
1176
|
volumes:
|
|
811
|
-
${devVolumes.join("\n")}${
|
|
1177
|
+
${devVolumes.join("\n")}${devDependsBlock}${devWorker}${devDatabase}${devRedis}
|
|
812
1178
|
volumes:
|
|
813
1179
|
${devNamedVolumes.join("\n")}
|
|
814
1180
|
`);
|