feat: migrate secrets to Infisical
This commit is contained in:
+149
-73
@@ -2,8 +2,8 @@
|
||||
|
||||
SHD.build runs in a Node 24 container through the official SvelteKit Node
|
||||
adapter. The image includes application code, migration and notification
|
||||
scripts, and native runtime dependencies. Environment files, databases, backups,
|
||||
and user uploads remain outside the image.
|
||||
scripts, the Infisical CLI, and native runtime dependencies. Databases, backups,
|
||||
user uploads, and Universal Auth bootstrap credentials remain outside the image.
|
||||
|
||||
## Persistent data
|
||||
|
||||
@@ -30,7 +30,7 @@ Staging defaults to:
|
||||
- Adapter canonical origin: `https://stage.shd.build`
|
||||
|
||||
Guide ratings and reports use Cloudflare Turnstile after repeated anonymous
|
||||
activity. Configure both values in each encrypted environment:
|
||||
activity. Configure both values under `/app` in each Infisical environment:
|
||||
|
||||
```text
|
||||
TURNSTILE_SITE_KEY=<public site key>
|
||||
@@ -44,12 +44,13 @@ and the request address to Cloudflare for verification; it never exposes
|
||||
valid secret, a visitor who reaches the challenge threshold cannot submit more
|
||||
anonymous ratings or Guide reports.
|
||||
|
||||
Edit its encrypted configuration locally:
|
||||
Edit staging configuration in Infisical project
|
||||
`2ee94b27-e599-4194-9797-3372d42e53f6`:
|
||||
|
||||
```sh
|
||||
npm run env:decrypt:staging
|
||||
# Fill in Discord and SMTP values.
|
||||
npm run env:encrypt:staging
|
||||
```text
|
||||
Environment: staging
|
||||
Application path: /app
|
||||
Scheduler path: /shared/scheduler
|
||||
```
|
||||
|
||||
Add `https://stage.shd.build/auth/discord/callback` to the Discord application's
|
||||
@@ -59,9 +60,43 @@ and configure the host's TLS reverse proxy to forward `stage.shd.build` to port
|
||||
a containerized reverse proxy or tunnel can reach it; firewall the port to
|
||||
trusted LAN/tunnel sources when the host is reachable from other networks.
|
||||
|
||||
The trusted staging runner decrypts `.env.staging` into a mode-`0600` runtime
|
||||
file. Gitea Actions holds the decryption key; Git and the container image do
|
||||
not.
|
||||
The trusted staging runner writes separate mode-`0600` Universal Auth client-ID
|
||||
and client-secret files for the app and scheduler. Compose mounts each pair only
|
||||
into its assigned service. Each container exchanges those credentials for a
|
||||
short-lived access token and writes the assigned paths to mode-`0600` temporary
|
||||
JSON files. The entrypoint rejects unknown secret names, deletes the token,
|
||||
transfers the exports to the dedicated `shd-runtime` UID 10001, and drops root
|
||||
before parsing them. The launcher deletes the files before validation,
|
||||
migration, or startup. Startup fails if the mounted credential files are
|
||||
readable by UID 10001 or by their group or other users. Runtime-created data
|
||||
retains group access for rollback to the previous container UID.
|
||||
|
||||
| Machine identity | Environment | Injected paths | Gitea variable | Gitea secret |
|
||||
| -------------------------------- | ----------- | --------------------------- | ------------------------------------------ | ---------------------------------------------- |
|
||||
| `shd-build-staging-app` | `staging` | `/app`, `/shared/scheduler` | `INFISICAL_STAGING_APP_CLIENT_ID` | `INFISICAL_STAGING_APP_CLIENT_SECRET` |
|
||||
| `shd-build-staging-scheduler` | `staging` | `/shared/scheduler` | `INFISICAL_STAGING_SCHEDULER_CLIENT_ID` | `INFISICAL_STAGING_SCHEDULER_CLIENT_SECRET` |
|
||||
| `shd-build-production-app` | `prod` | `/app`, `/shared/scheduler` | `INFISICAL_PRODUCTION_APP_CLIENT_ID` | `INFISICAL_PRODUCTION_APP_CLIENT_SECRET` |
|
||||
| `shd-build-production-scheduler` | `prod` | `/shared/scheduler` | `INFISICAL_PRODUCTION_SCHEDULER_CLIENT_ID` | `INFISICAL_PRODUCTION_SCHEDULER_CLIENT_SECRET` |
|
||||
|
||||
The self-hosted Free plan allows five machine identities and does not include
|
||||
Custom Project Roles. The four runtime identities use the built-in Viewer role,
|
||||
which can read the complete project. Service-specific paths limit injected
|
||||
values, but they do not restrict what a stolen root credential could request.
|
||||
Use an Enterprise license with Custom Project Roles, or split runtime scopes
|
||||
into separate projects, before treating identity authorization as least
|
||||
privilege.
|
||||
|
||||
An Infisical outage does not stop a running container. A new container or direct
|
||||
restart fails closed until `https://secrets.jb.codes` becomes available and the
|
||||
identity can authenticate.
|
||||
|
||||
Keep the old `.env.*.runtime` files and `DOTENV_PRIVATE_KEY_*` Gitea secrets
|
||||
only through the first successful staging and production cutovers. The rollback
|
||||
path may need them to start the previous image. After both environments pass
|
||||
direct restart checks, rotate every migrated provider credential in Infisical,
|
||||
remove the old dotenv private keys and `SCHEDULER_TOKEN_PRODUCTION` from Gitea,
|
||||
and delete local and hosted dotenv key material. Removing encrypted files from
|
||||
the current Git tree does not revoke values stored in Git history.
|
||||
|
||||
The staging container disables default-user seeding. On a brand-new staging
|
||||
database, the existing bootstrap policy grants Admin to the first real account
|
||||
@@ -99,31 +134,51 @@ Restart the runner session after adding it to the Docker group.
|
||||
|
||||
In `https://git.jb.codes/jb/shd.build/settings/actions`:
|
||||
|
||||
1. Add repository secret `DOTENV_PRIVATE_KEY_STAGING`, using the matching value
|
||||
from the ignored local `.env.keys`.
|
||||
2. Optionally add repository variable `STAGE_DEPLOY_PATH`. It defaults to
|
||||
1. Add repository secrets `INFISICAL_STAGING_APP_CLIENT_SECRET` and
|
||||
`INFISICAL_STAGING_SCHEDULER_CLIENT_SECRET`.
|
||||
2. Add repository variables `INFISICAL_STAGING_APP_CLIENT_ID` and
|
||||
`INFISICAL_STAGING_SCHEDULER_CLIENT_ID`.
|
||||
3. Optionally add repository variable `STAGE_DEPLOY_PATH`. It defaults to
|
||||
`/opt/shd-build-stage`.
|
||||
3. Optionally add repository variable `STAGE_PORT`. It defaults to `5569`.
|
||||
4. Ensure Actions are enabled and the `stage-deploy` runner is online.
|
||||
4. Optionally add repository variable `STAGE_PORT`. It defaults to `5569`.
|
||||
5. Ensure Actions are enabled and the `stage-deploy` runner is online.
|
||||
|
||||
`.gitea/workflows/deploy-staging.yml` runs for pushes to `main` and manual
|
||||
dispatches. The trusted staging runner builds each commit-tagged image locally,
|
||||
so the workflow needs no registry credentials. Gitea assigns `gitea.run_number`
|
||||
to `SHD_BUILD_NUMBER`; each new workflow run receives the next integer, while
|
||||
retries of the same run retain their number.
|
||||
dispatches. The trusted staging runner builds each image locally with a tag that
|
||||
contains the commit, workflow run, and attempt, so the workflow needs no
|
||||
registry credentials and a retry cannot overwrite the active image. Gitea
|
||||
assigns `gitea.run_number` to `SHD_BUILD_NUMBER`; each new workflow run receives
|
||||
the next integer, while retries of the same run retain their number.
|
||||
|
||||
Staging and production deployments, ISAC exports, and production-to-staging
|
||||
synchronization share the `shd-build-host-maintenance` concurrency group. Gitea
|
||||
runs only one of these database maintenance workflows on the deployment host at
|
||||
a time and never cancels one in progress.
|
||||
|
||||
## Deployment lifecycle
|
||||
|
||||
The workflow:
|
||||
The workflow stages Compose, credentials, and release metadata in an isolated
|
||||
candidate directory. It leaves the canonical deployment files unchanged until
|
||||
the candidate passes preflight, backup, and migration. The workflow then:
|
||||
|
||||
1. installs dependencies, runs `npm run check`, and produces the Node build;
|
||||
1. installs dependencies, runs `npm run check` and `npm test`, and produces the
|
||||
Node build;
|
||||
2. builds the candidate container while the current site remains online;
|
||||
3. stops only the staging application and notification scheduler;
|
||||
4. creates a timestamped JSON backup if the staging database exists;
|
||||
5. applies additive, repeatable migrations;
|
||||
6. starts the application and scheduler with the new image;
|
||||
7. waits for `/api/health` to report healthy;
|
||||
8. retains the logs and exits unsuccessfully if health verification fails.
|
||||
3. starts one-off app and scheduler containers to verify Infisical access and
|
||||
validate the candidate environment;
|
||||
4. stops only the staging application and notification scheduler;
|
||||
5. creates a timestamped JSON backup if the staging database exists;
|
||||
6. applies additive, repeatable migrations in a one-off container;
|
||||
7. promotes the candidate files to the canonical deployment paths;
|
||||
8. starts the application and scheduler with the new image;
|
||||
9. validates the app environment and reruns the repeatable migration in the app
|
||||
entrypoint before serving;
|
||||
10. verifies `/api/health` and scheduler liveness;
|
||||
11. restores the previous canonical files and verifies the previous app and
|
||||
scheduler after a failed stop, backup, migration, startup, or health check.
|
||||
|
||||
On a failed health check, the deploy script prints the final 150 application log
|
||||
lines and exits unsuccessfully. It does not create a separate log artifact.
|
||||
|
||||
The outage covers backup, migration, and container startup. Migrations may also
|
||||
remove records that use retired data formats when a release explicitly ends
|
||||
@@ -145,11 +200,10 @@ feed remain published; the importer performs no automatic removal. The scheduler
|
||||
runs notification delivery before it starts the bounded Ubisoft request, so a
|
||||
slow provider does not postpone notifications for the current tick.
|
||||
|
||||
`deploy/compose.production.yml` declares `UBISOFT_NEWS_IMPORT_ENABLED=true`,
|
||||
while the staging Compose file declares `false`. Development does not set the
|
||||
flag and remains disabled because the importer requires the exact value `true`.
|
||||
The application process evaluates the flag when it handles
|
||||
`/api/internal/scheduler`.
|
||||
The runtime launcher sets `UBISOFT_NEWS_IMPORT_ENABLED=true` for production and
|
||||
`false` for staging. Development does not set the flag, so the importer remains
|
||||
disabled because it requires the exact value `true`. The application process
|
||||
evaluates the flag when it handles `/api/internal/scheduler`.
|
||||
|
||||
## Prelaunch operation
|
||||
|
||||
@@ -216,10 +270,11 @@ channel, keep it out of Git, then copy it to the staging host and run:
|
||||
/secure/path/shd-stage-bootstrap-YYYY-MM-DDTHH-MM-SSZ.json
|
||||
```
|
||||
|
||||
The bootstrap flow stops the app and scheduler, creates a safety backup of the
|
||||
target, verifies that staging is unused, imports the entire development database
|
||||
and uploads, and restarts both services. Delete the transfer copy from the
|
||||
staging host after verifying the imported record counts and critical entities.
|
||||
The bootstrap flow requires both services to stop, copies the bundle into the
|
||||
private data volume, assigns it to the runtime UID, creates a safety backup,
|
||||
verifies that staging is unused, imports the entire development database and
|
||||
uploads, deletes the volume copy, and restarts both services. Delete the host
|
||||
transfer copy after verifying the imported record counts and critical entities.
|
||||
The copy under the local ignored backup directory remains the recovery source.
|
||||
|
||||
Because this is a full initial clone, development session tokens and account
|
||||
@@ -279,16 +334,21 @@ environment, port, and deployment workflow:
|
||||
- Database: `/app/data/shd-build-production.db`
|
||||
- Persistent volume: `shd-build-production-data`
|
||||
|
||||
Routine pushes leave production data unchanged. The production deploy stops its
|
||||
application and scheduler, creates a timestamped JSON backup, applies repeatable
|
||||
migrations, restarts, and verifies database health. Keep
|
||||
`docker compose down --volumes` away from production.
|
||||
Routine pushes preserve production data unless an included migration removes a
|
||||
retired prototype format. The production deploy stops its application and
|
||||
scheduler, creates a timestamped JSON backup, applies repeatable migrations,
|
||||
restarts, and verifies database health. Keep `docker compose down --volumes`
|
||||
away from production.
|
||||
|
||||
Add these repository Action secrets:
|
||||
|
||||
- `DOTENV_PRIVATE_KEY_PRODUCTION`, matching the ignored local `.env.keys`
|
||||
- `SCHEDULER_TOKEN_PRODUCTION`, a long random value used only by the internal
|
||||
scheduler endpoint
|
||||
- `INFISICAL_PRODUCTION_APP_CLIENT_SECRET`
|
||||
- `INFISICAL_PRODUCTION_SCHEDULER_CLIENT_SECRET`
|
||||
|
||||
Add the matching repository variables:
|
||||
|
||||
- `INFISICAL_PRODUCTION_APP_CLIENT_ID`
|
||||
- `INFISICAL_PRODUCTION_SCHEDULER_CLIENT_ID`
|
||||
|
||||
Optional repository variables are `PRODUCTION_DEPLOY_PATH` (default
|
||||
`/opt/shd-build-production`) and `PRODUCTION_PORT` (default `3102`). The
|
||||
@@ -299,12 +359,12 @@ workflow run number supplies the production `SHD_BUILD_NUMBER`.
|
||||
Prepare the host directory once from an interactive SSH session:
|
||||
|
||||
```sh
|
||||
sudo install -d -o jb -g jb -m 0750 /opt/shd-build-production
|
||||
sudo install -d -o act_runner -g act_runner -m 0750 /opt/shd-build-production
|
||||
```
|
||||
|
||||
Generate the scheduler secret locally without printing it into a shared log,
|
||||
then enter it directly in Gitea's repository-secret form. Do not commit it or
|
||||
reuse the staging scheduler token.
|
||||
Store a separate production `SCHEDULER_TOKEN` under Infisical path
|
||||
`/shared/scheduler`. The application and scheduler fetch the same value from
|
||||
that path. Do not reuse the staging token.
|
||||
|
||||
### Initial production bootstrap
|
||||
|
||||
@@ -322,9 +382,11 @@ Copy the resulting ignored JSON bundle to the deployment host over SSH, then:
|
||||
/secure/path/shd-production-bootstrap-YYYY-MM-DDTHH-MM-SSZ.json
|
||||
```
|
||||
|
||||
The importer verifies the bundle target, creates a production safety backup, and
|
||||
refuses to replace a production database containing users, builds, audit events,
|
||||
or proposals. Remove the remote transfer copy after verification.
|
||||
The bootstrap script requires both services to stop, stages the bundle in the
|
||||
private data volume with runtime-only access, and removes that copy after the
|
||||
import. The importer verifies the bundle target, creates a production safety
|
||||
backup, and refuses to replace a production database containing users, builds,
|
||||
audit events, or proposals. Remove the remote host copy after verification.
|
||||
|
||||
### Promote one News article
|
||||
|
||||
@@ -340,13 +402,14 @@ managed-image metadata, and image bytes. It excludes comments, reports, user
|
||||
records, Builds, and ISAC data.
|
||||
|
||||
After deploying the matching application schema, stop the production app and
|
||||
scheduler and mount the transferred bundle into a one-off application container.
|
||||
Run `scripts/article-transfer.mjs import` with `ARTICLE_IMPORT_ENABLED=true` and
|
||||
scheduler. Copy the transferred bundle into `/app/data` with `docker cp`, assign
|
||||
it to `shd-runtime:node` with mode `0600` through a root one-off container, then
|
||||
run `scripts/article-transfer.mjs import` with `ARTICLE_IMPORT_ENABLED=true` and
|
||||
`--confirm-article-import`. The importer creates a production database backup
|
||||
before opening the database, resolves the article owner by stable ID or
|
||||
username, rejects ID and slug conflicts, verifies image checksums, and appends
|
||||
`editorial_content / news.promoted`. Restart and health-check both services,
|
||||
then remove the transferred bundle from the host.
|
||||
`editorial_content / news.promoted`. Remove both bundle copies, restart the app
|
||||
and scheduler, and health-check both services.
|
||||
|
||||
### Pull production ISAC data into development
|
||||
|
||||
@@ -373,11 +436,11 @@ safety backups before replacement. Deployments do not run this flow.
|
||||
Image Library assets use stable IDs and can be promoted additively from local
|
||||
development. Export a bundle after finishing local uploads and metadata edits:
|
||||
|
||||
Set the server-only `REMOVE_BG_API_KEY` in each encrypted environment that
|
||||
accepts ISAC uploads. It is sent only from the server in the `X-Api-Key` header.
|
||||
Transparent icon uploads skip remove.bg. Opaque sources use its preview output,
|
||||
then pass through local trimming, white normalization, WebP conversion, and
|
||||
storage.
|
||||
Set the server-only `REMOVE_BG_API_KEY` under `/app` in each Infisical
|
||||
environment that accepts ISAC uploads. The server sends it only in the
|
||||
`X-Api-Key` header. Transparent icon uploads skip remove.bg. Opaque sources use
|
||||
its preview output, then pass through local trimming, white normalization, WebP
|
||||
conversion, and storage.
|
||||
|
||||
The application stores a remove.bg capacity snapshot and refreshes it from the
|
||||
internal scheduler. `REMOVE_BG_CREDIT_CHECK_HOURS` controls the account lookup
|
||||
@@ -399,19 +462,33 @@ embedded database is unlocked, and run the merge using the target image:
|
||||
```sh
|
||||
# Staging
|
||||
cd /opt/shd-build-stage
|
||||
docker compose -f compose.staging.yml stop scheduler app
|
||||
docker compose -f compose.staging.yml run --rm \
|
||||
-v /secure/path:/import:ro app \
|
||||
node scripts/managed-images-import.mjs /import/managed-images.json --confirm-merge
|
||||
docker compose -f compose.staging.yml up -d app scheduler
|
||||
docker compose --env-file .env.deploy -f compose.staging.yml stop scheduler app
|
||||
docker compose --env-file .env.deploy -f compose.staging.yml create app
|
||||
app_container=$(docker compose --env-file .env.deploy -f compose.staging.yml ps --all --quiet app)
|
||||
docker cp /secure/path/managed-images.json "$app_container:/app/data/.managed-images-import.json"
|
||||
docker compose --env-file .env.deploy -f compose.staging.yml run --rm --no-deps \
|
||||
--entrypoint sh --user root app -c \
|
||||
'chown shd-runtime:node /app/data/.managed-images-import.json && chmod 600 /app/data/.managed-images-import.json'
|
||||
docker compose --env-file .env.deploy -f compose.staging.yml run --rm --no-deps app \
|
||||
node scripts/managed-images-import.mjs /app/data/.managed-images-import.json --confirm-merge
|
||||
docker compose --env-file .env.deploy -f compose.staging.yml run --rm --no-deps \
|
||||
--entrypoint rm --user root app -f /app/data/.managed-images-import.json
|
||||
docker compose --env-file .env.deploy -f compose.staging.yml up -d app scheduler
|
||||
|
||||
# Production
|
||||
cd /opt/shd-build-production
|
||||
docker compose -f compose.production.yml stop scheduler app
|
||||
docker compose -f compose.production.yml run --rm \
|
||||
-v /secure/path:/import:ro app \
|
||||
node scripts/managed-images-import.mjs /import/managed-images.json --confirm-merge
|
||||
docker compose -f compose.production.yml up -d app scheduler
|
||||
docker compose --env-file .env.deploy -f compose.production.yml stop scheduler app
|
||||
docker compose --env-file .env.deploy -f compose.production.yml create app
|
||||
app_container=$(docker compose --env-file .env.deploy -f compose.production.yml ps --all --quiet app)
|
||||
docker cp /secure/path/managed-images.json "$app_container:/app/data/.managed-images-import.json"
|
||||
docker compose --env-file .env.deploy -f compose.production.yml run --rm --no-deps \
|
||||
--entrypoint sh --user root app -c \
|
||||
'chown shd-runtime:node /app/data/.managed-images-import.json && chmod 600 /app/data/.managed-images-import.json'
|
||||
docker compose --env-file .env.deploy -f compose.production.yml run --rm --no-deps app \
|
||||
node scripts/managed-images-import.mjs /app/data/.managed-images-import.json --confirm-merge
|
||||
docker compose --env-file .env.deploy -f compose.production.yml run --rm --no-deps \
|
||||
--entrypoint rm --user root app -f /app/data/.managed-images-import.json
|
||||
docker compose --env-file .env.deploy -f compose.production.yml up -d app scheduler
|
||||
```
|
||||
|
||||
Each import creates a full target database backup before changes. The importer
|
||||
@@ -422,9 +499,8 @@ After reviewing an intentional metadata or file replacement, add
|
||||
verifying both environments because it may contain unpublished visual assets and
|
||||
creator identifiers.
|
||||
|
||||
The `.env.staging` and `.env.production` npm import commands are intended for
|
||||
maintenance performed directly in an environment where those database and
|
||||
storage paths are mounted:
|
||||
The staging and production npm import commands fetch `/app` from Infisical. Run
|
||||
them only where the target database and storage paths are mounted:
|
||||
|
||||
```sh
|
||||
npm run images:import:staging -- /path/to/bundle.json --confirm-merge
|
||||
|
||||
+7
-15
@@ -911,12 +911,9 @@ The notification router sends Data Change review items to agents who hold
|
||||
delivery for each authorized queue. Submission creates Immediate alerts;
|
||||
`oversight_notification_queue` stores consolidated alerts.
|
||||
|
||||
Production must run the idempotent dispatcher once per minute from one scheduler
|
||||
instance:
|
||||
|
||||
```text
|
||||
* * * * * cd /path/to/shd.build && npm run notifications:oversight:production
|
||||
```
|
||||
The production Compose scheduler calls the internal scheduler endpoint once per
|
||||
minute. That endpoint runs the idempotent dispatcher in the application process,
|
||||
which owns the embedded-database connection. Do not run a second host cron job.
|
||||
|
||||
The dispatcher groups due rows by reviewer, queue, cadence, and delivery window.
|
||||
Daily rows use the next 10:00 AM wall-clock boundary in `America/New_York`,
|
||||
@@ -941,12 +938,7 @@ default welcome introduces News, Database, Builds, Guides, Builder, ISAC
|
||||
Contributor Mode, and the SHD Discord community.
|
||||
|
||||
The registration request delivers Immediate definitions.
|
||||
`automated_system_message_deliveries` stores definitions with a delay.
|
||||
Production must run the delayed dispatcher once per minute:
|
||||
|
||||
```text
|
||||
* * * * * cd /path/to/shd.build && npm run notifications:system:production
|
||||
```
|
||||
|
||||
Only one dispatcher instance should run for the embedded database. Delivered
|
||||
rows retain both their delivery timestamp and generated notification ID.
|
||||
`automated_system_message_deliveries` stores definitions with a delay. The same
|
||||
Compose scheduler tick runs the delayed dispatcher. Only one scheduler instance
|
||||
should call the embedded-database application. Delivered rows retain both their
|
||||
delivery timestamp and generated notification ID.
|
||||
|
||||
Reference in New Issue
Block a user