Compare commits

31 Commits

Author SHA1 Message Date
93e3ece390 docs: record calendar bootstrap topology 2026-08-23 01:57:39 +08:00
36ac6ee34e feat: package calendar bootstrap tool 2026-08-23 01:55:54 +08:00
6634b6c4af fix: allow worker outbound provider access 2026-08-23 01:53:51 +08:00
994eec33dc fix: run Redis with image-supported privilege drop 2026-08-22 18:33:17 +03:30
64404bb7cf feat: deploy through shared Caddy ingress 2026-08-22 21:48:26 +08:00
e2decb9e01 feat: start scheduler in local development 2026-08-22 17:25:00 +08:00
35a1101c8b docs: record AI growth coach operating contracts 2026-08-22 17:24:48 +08:00
2ff4bba418 docs: document local AI growth controls 2026-08-21 00:23:33 +03:30
59932e1eae docs: promote AI growth analyst capability 2026-08-21 00:09:21 +03:30
9c88ea603e fix: track Windows local process trees safely 2026-08-20 20:02:55 +03:30
08b43b427a feat: add native Windows local orchestration 2026-08-20 19:30:30 +03:30
67777bde5b docs(sdd): authorize platform implementation 2026-08-20 19:17:50 +03:30
eec3709533 docs: plan platform implementation tasks 2026-08-20 19:07:36 +03:30
21085c7405 docs: define Qlockify adaptation contract 2026-08-20 19:07:31 +03:30
3a854ad500 docs(plan): record cross-repository review 2026-08-20 19:00:15 +03:30
d05a1ad276 docs(spec): align immutable dataset operations 2026-08-20 19:00:13 +03:30
480fd207c7 docs: advance platform build gate 2026-08-20 18:55:05 +03:30
83d2e13d14 docs: approve platform operations runbooks 2026-08-20 18:54:59 +03:30
e93eca1384 docs: approve platform operations specification 2026-08-20 18:54:54 +03:30
ec7a7753f6 docs: replan manual platform delivery 2026-08-20 18:35:36 +03:30
789f60e2ca docs: align operations runbooks with manual releases 2026-08-20 18:35:26 +03:30
892d79b6c3 docs: adopt manual server deployment 2026-08-20 18:35:16 +03:30
c16834b153 docs: resolve font deployment decision 2026-08-20 18:13:01 +03:30
0528fb0a8d docs: plan platform implementation 2026-08-20 18:11:59 +03:30
db4ddab42b docs: add bootstrap and recovery runbooks 2026-08-20 18:11:41 +03:30
f899b3c01b docs: record platform release decisions 2026-08-20 18:11:24 +03:30
d651e0c845 docs: specify platform operations 2026-08-20 18:11:01 +03:30
07c61fdd09 docs: correct source record grain 2026-08-20 17:29:28 +03:30
46ea2dcd15 docs: record postgres session storage 2026-08-20 17:21:26 +03:30
886ddb3208 docs: bound identity to demo adapter 2026-08-20 17:14:41 +03:30
d327750cf3 docs: record identity security invariant 2026-08-20 17:06:44 +03:30
28 changed files with 2625 additions and 19 deletions

30
.env.example Normal file
View File

@@ -0,0 +1,30 @@
# Copy to .env on the server, replace every change-me value, then chmod 600 .env.
COMPOSE_PROJECT_NAME=zarinpal_analytics
CADDY_NETWORK=caddy_proxy
# PostgreSQL. Keep the generated password URL-safe (for example: openssl rand -hex 32).
POSTGRES_DB=zarinpal
POSTGRES_USER=zarinpal
POSTGRES_PASSWORD=change-me-with-a-random-hex-value
# Redis is private but authenticated. Keep this value URL-safe too.
REDIS_PASSWORD=change-me-with-a-different-random-hex-value
# Absolute host path to the immutable first-import archive.
IMPORT_FILE=/srv/zarinpal-data/challenge_data.csv.gz
# Reviewed calendar bootstrap input and non-secret audit identities.
CALENDAR_FILE=/srv/zarinpal-data/reviewed-calendar.json
CALENDAR_CURRENT_JALALI_YEAR=1405
CALENDAR_REVIEWER=change-me-independent-reviewer
CALENDAR_OPERATOR=change-me-platform-operator
# Optional backup settings. These are reserved for the backup/restore scripts.
S3_BACKUP_BUCKET=
S3_BACKUP_PREFIX=backup/zarinpal-analytics
S3_BACKUP_ENDPOINT_URL=
S3_BACKUP_ACCESS_KEY_ID=
S3_BACKUP_SECRET_ACCESS_KEY=
BACKUP_ENCRYPTION_PASSPHRASE=
BACKUP_LOCAL_KEEP_LATEST=3
BACKUP_REMOTE_KEEP_LATEST=7

2
.gitignore vendored
View File

@@ -6,6 +6,7 @@
.env .env
.env.* .env.*
!.env.example !.env.example
/.local/
/backups/ /backups/
/certbot/conf/ /certbot/conf/
/certbot/www/ /certbot/www/
@@ -16,4 +17,3 @@
.vscode/ .vscode/
.DS_Store .DS_Store
Thumbs.db Thumbs.db

View File

@@ -2,7 +2,7 @@
## Current phase gate ## Current phase gate
This project is in specification mode. Do not scaffold or implement application/deployment code until the relevant capability spec is explicitly approved by the user. Documentation, research notes, Git hygiene, and spec revisions are allowed. This repository is authorized for SDD Phase 4 implementation as of 2026-08-20. Follow `tasks/todo.md` in dependency order and keep the approved platform specification, adaptation contract, runbooks, and ADRs authoritative. Production operations and destructive actions retain their task-specific human approval guards.
## Sources of truth ## Sources of truth
@@ -28,7 +28,7 @@ This repository owns deployment concerns only. Backend and frontend directories
## Operational baseline ## Operational baseline
- Everything runs through Docker, Docker Compose, Bash scripts, and environment files/templates. - Everything runs through Docker, Docker Compose, Bash scripts, and environment files/templates.
- The CSV-to-PostgreSQL workflow is a one-time administrative bootstrap/recovery command, never a scheduled ingestion service. ZarinPal owns future production payment writes. - The CSV-to-PostgreSQL workflow is a one-time administrative bootstrap/recovery command, never a scheduled ingestion service. MVP payment facts remain immutable after that import; future live writes require a new approved integration contract.
- An idempotent authenticated start request enqueues overview, payment-performance, fees, and peer-comparison jobs for bounded parallel calculation by a separate on-demand Go worker. Redis caches/coordinates each section for 30 days, and a separate authenticated SSE request streams partial completion. This worker is event-driven, never scheduled. - An idempotent authenticated start request enqueues overview, payment-performance, fees, and peer-comparison jobs for bounded parallel calculation by a separate on-demand Go worker. Redis caches/coordinates each section for 30 days, and a separate authenticated SSE request streams partial completion. This worker is event-driven, never scheduled.
- The worker uses Asynq pinned to `v0.26.0`. Asynq is pre-`v1.0.0`; never use a floating dependency or upgrade it without compatibility review and passing queue/retry/recovery tests. - The worker uses Asynq pinned to `v0.26.0`. Asynq is pre-`v1.0.0`; never use a floating dependency or upgrade it without compatibility review and passing queue/retry/recovery tests.
- Each section attempt has a 60-second timeout and at most two automatic retries for transient failures using exponential backoff with jitter. Deterministic failures are not retried. - Each section attempt has a 60-second timeout and at most two automatic retries for transient failures using exponential backoff with jitter. Deterministic failures are not retried.
@@ -39,4 +39,4 @@ This repository owns deployment concerns only. Backend and frontend directories
- Health checks distinguish liveness from readiness. - Health checks distinguish liveness from readiness.
- Backups are encrypted where supported, validated, retention-aware, and paired with rehearsed restores. - Backups are encrypted where supported, validated, retention-aware, and paired with rehearsed restores.
- Restore operations must validate the exact destination and require an explicit production confirmation guard. - Restore operations must validate the exact destination and require an explicit production confirmation guard.
- CI/CD deploys immutable revisions, runs pre/post-deploy checks, and has a documented rollback. - Production deployment is manual and Qlockify-shaped: a trusted Docker-group user fast-forwards all three clean checkouts to latest `origin/main`, records their exact commit SHAs, builds on the server, runs pre/post-deploy checks, and records rollback SHAs. Do not add CI/CD, a runner, registry, or automated cross-repository deploy without a new approved decision.

View File

@@ -1,7 +1,7 @@
# Capability Map # Capability Map
Status: **approved Phase 0 baseline** Status: **approved Phase 0 baseline**
Implementation authorization: **not granted** Implementation authorization: **granted by user on 2026-08-20**
## Product intent ## Product intent
@@ -13,13 +13,13 @@ The hackathon demo will seed one account per merchant. Where merchant profile fi
| Order | Capability | Responsibility | Depends on | MVP | | Order | Capability | Responsibility | Depends on | MVP |
|---:|---|---|---|:---:| |---:|---|---|---|:---:|
| 1 | Data foundation | PostgreSQL model, one-time CSV bootstrap, provenance, validation, quality reporting, synthetic merchant profiles; future source writes are owned by ZarinPal | — | Yes | | 1 | Data foundation | PostgreSQL model, one-time CSV bootstrap, provenance, validation, quality reporting, synthetic merchant profiles; MVP payment facts are immutable afterward | — | Yes |
| 2 | Identity and access | Merchant credentials, sessions, tenant isolation, demo account seeding | Data foundation | Yes | | 2 | Identity and access | Replaceable demo credential adapter, sessions, tenant isolation, demo account seeding; future ZarinPal SSO is external | Data foundation | Yes |
| 3 | Analytics engine | Deterministic metrics, cohorts, trends, diagnostic drill-downs, bounded parallel on-demand section jobs, 30-day Redis cache, PostgreSQL revision invalidation | Data foundation | Yes | | 3 | Analytics engine | Deterministic metrics, cohorts, trends, diagnostic drill-downs, bounded parallel on-demand section jobs, and a 30-day Redis cache namespaced by the sole completed immutable dataset snapshot | Data foundation | Yes |
| 4 | Merchant API | Stable authenticated interfaces and tenant-scoped SSE for partial analytics delivery | Identity and access, analytics engine | Yes | | 4 | Merchant API | Stable authenticated interfaces and tenant-scoped SSE for partial analytics delivery | Identity and access, analytics engine | Yes |
| 5 | Merchant dashboard | Persian RTL dashboard, responsive navigation, accessible charts/tables and explanations | Merchant API | Yes | | 5 | Merchant dashboard | Persian RTL dashboard, responsive navigation, accessible charts/tables and explanations | Merchant API | Yes |
| 6 | AI assistance | AvalAI-backed explanation/recommendation workflow over sanitized aggregates | Analytics engine, merchant API | No | | 6 | AI growth analyst | Deterministic 90-day event-aware forecasts, persisted report snapshots, cost controls, and bounded AvalAI interpretation over sanitized evidence | Analytics engine, merchant API | Yes |
| 7 | Platform operations | Compose, Nginx, CI/CD, observability, backup/restore, release and rollback | All deployables | Yes | | 7 | Platform operations | Compose, Nginx, manual deployment, phased observability, backup/restore, release and rollback | All deployables | Yes |
Build sequence: data foundation → identity and analytics → API → dashboard → platform operations → AI assistance. Build sequence: data foundation → identity and analytics → API → dashboard → platform operations → AI assistance.
@@ -31,8 +31,8 @@ Build sequence: data foundation → identity and analytics → API → dashboard
## Dataset facts that constrain the design ## Dataset facts that constrain the design
- Source grain: one payment attempt, uniquely identified by `(session_key, try_seq)`. - Source grain: one attempt-or-no-attempt session record, uniquely identified by `(session_key, try_seq)`. A `try_seq = 0`/`NoAttempt` marker creates no normalized attempt.
- Observed volume: 2,213,289 attempts, 2,062,839 sessions, 343 merchants, 348 terminals, 5 categories. - Observed volume: 2,213,289 source records, including 263,936 no-attempt markers and 1,949,353 actual attempts; 2,062,839 sessions, 343 merchants, 348 terminals, and 5 categories.
- Observed period: 2026-01-01 through 2026-07-01. - Observed period: 2026-01-01 through 2026-07-01.
- Nullability is material: response, PSP, issuer/card, verification timing, and settlement fields are incomplete. - Nullability is material: response, PSP, issuer/card, verification timing, and settlement fields are incomplete.
- Source `adjusted_fee` is the merchant-fee field for this product and is normalized once per session as a real Rial amount; the challenge source name/value was confidentiality-adjusted. - Source `adjusted_fee` is the merchant-fee field for this product and is normalized once per session as a real Rial amount; the challenge source name/value was confidentiality-adjusted.
@@ -45,18 +45,23 @@ Build sequence: data foundation → identity and analytics → API → dashboard
3. Every displayed metric has a definition, source fields, filters, time window, and drill-down path. 3. Every displayed metric has a definition, source fields, filters, time window, and drill-down path.
4. Dataset imports are repeatable and produce a versioned manifest with checksum, counts, timestamps, and validation results. 4. Dataset imports are repeatable and produce a versioned manifest with checksum, counts, timestamps, and validation results.
5. Secrets stay outside Git; examples contain names and safe placeholders only. 5. Secrets stay outside Git; examples contain names and safe placeholders only.
6. AI is post-MVP, receives only minimized/sanitized aggregates, and cannot become the source of record. 6. AI growth analysis is feature-flagged, receives only minimized/sanitized aggregates, and cannot become the source of record. Deterministic forecasts and cached reports remain usable when AvalAI is disabled, unavailable, or over budget.
7. External calls have timeouts and bounded retries; jobs are idempotent and observable. 7. External calls have timeouts and bounded retries; jobs are idempotent and observable.
8. Production changes are reversible and backed by documented health checks and rollback steps. 8. Production changes are reversible and backed by documented health checks and rollback steps.
9. `Paid` and `Verified` are both successful bank payments; `Paid` still requires merchant verification and `Verified` is finalized. Paid-volume and recovery metrics include both while verification metrics keep them separate. 9. `Paid` and `Verified` are both successful bank payments; `Paid` still requires merchant verification and `Verified` is finalized. Paid-volume and recovery metrics include both while verification metrics keep them separate.
10. Merchant fees are real Rial amounts stored/summed once per session; repeated attempt rows never multiply a fee. 10. Merchant fees are real Rial amounts stored/summed once per session; repeated attempt rows never multiply a fee.
11. MVP reporting periods are limited to daily, Saturday–Friday weekly, Jalali-month, and Persian-season views with consistent `Asia/Tehran` boundaries across database, API, and frontend. 11. MVP reporting periods are limited to daily, Saturday–Friday weekly, Jalali-month, and Persian-season views with consistent `Asia/Tehran` boundaries across database, API, and frontend.
12. An idempotent start request enqueues overview, payment-performance, fees, and peer-comparison sections for bounded parallel calculation by a separate Go worker; Redis caches each typed section for 30 days under merchant/cohort and metric-definition revisions. 12. An idempotent start request enqueues overview, payment-performance, fees, and peer-comparison sections for bounded parallel calculation by a separate Go worker; Redis caches each typed section for 30 days under the merchant, completed dataset snapshot, and metric-definition version.
13. A separate authenticated SSE request exposes each section as it becomes ready. Redis and the on-demand worker remain private, and Redis is never a source of truth or backup target. 13. A separate authenticated SSE request exposes each section as it becomes ready. Redis and the on-demand worker remain private, and Redis is never a source of truth or backup target.
14. Each section attempt is limited to 60 seconds and at most two transient retries with exponential backoff/jitter; deterministic errors fail immediately. 14. Each section attempt is limited to 60 seconds and at most two transient retries with exponential backoff/jitter; deterministic errors fail immediately.
15. The worker pins pre-1.0 Asynq `v0.26.0`, runs at 16 jobs globally and 4/merchant by default, and changes require reviewed dependency/load evidence. 15. The worker pins pre-1.0 Asynq `v0.26.0`, runs at 16 jobs globally and 4/merchant by default, and changes require reviewed dependency/load evidence.
16. Start, manual-retry, and SSE limits use independent authenticated-user/merchant buckets and return `429` with `Retry-After` before work is queued. SSE heartbeats run every 15 seconds and authorized run-event replay lasts one hour. 16. Start, manual-retry, and SSE limits use independent authenticated-user/merchant buckets and return `429` with `Retry-After` before work is queued. SSE heartbeats run every 15 seconds and authorized run-event replay lasts one hour.
17. Peer outputs use the median of one independently calculated value per eligible merchant plus a direction-aware merchant performance percentile. Equal merchant weighting is intentional because transaction weighting would let the dataset's largest merchants dominate the benchmark. 17. Peer outputs use the median of one independently calculated value per eligible merchant plus a direction-aware merchant performance percentile. Equal merchant weighting is intentional because transaction weighting would let the dataset's largest merchants dominate the benchmark.
18. Authentication uses revocable server-side sessions and an opaque host-only `Secure`, `HttpOnly`, `SameSite=Lax` cookie. Passwords use Argon2id; sessions expire after 12 idle hours or seven absolute days and are revoked on logout/account disablement. Demo credentials are generated once and never stored in Git.
19. Demo authentication stays replaceable and minimal because ZarinPal SSO will own production identity after acceptance. Failed logins are limited by username and IP without permanent lockout; mutations require CSRF and same-origin checks; each user has at most two active sessions.
20. Demo sessions live in PostgreSQL and store only SHA-256 digests of 32-byte random tokens. Activity writes are coalesced to five-minute windows; Redis remains disposable and is not the session source of truth.
21. AI growth reports are keyed by authenticated merchant/optional terminal plus data, catalog, analytics, prompt/schema, and model versions. PostgreSQL is authoritative; Redis may coordinate locks, limits, budgets, and hot reads only.
22. Merchant page visits reuse the current matching report. At most one explicit merchant-wide refresh is allowed per Tehran calendar day, and budget exhaustion degrades to deterministic partial output instead of hiding analytics.
## Required capability specs ## Required capability specs

View File

@@ -2,7 +2,9 @@
This repository is the operational control plane for the hackathon project. It will own Docker Compose, Nginx, environment templates, deployment automation, backup/restore scripts, and cross-repository release orchestration. Product code belongs in the backend and frontend repositories. This repository is the operational control plane for the hackathon project. It will own Docker Compose, Nginx, environment templates, deployment automation, backup/restore scripts, and cross-repository release orchestration. Product code belongs in the backend and frontend repositories.
Current state: specification and repository bootstrap only. No application or deployment implementation has been approved yet. The production stack is Docker Compose based and connects to the existing shared Caddy deployment. This repository does not publish host ports and does not run a second TLS proxy.
For direct Windows development without Docker Compose, see [`docs/LOCAL-DEVELOPMENT.md`](docs/LOCAL-DEVELOPMENT.md). This helper is deliberately separate from the production Compose contract.
## Repository topology ## Repository topology
@@ -11,10 +13,86 @@ zarinpal-analytics-deployment/ # this repository
├── backend/ # ignored checkout of zarinpal-analytics-backend ├── backend/ # ignored checkout of zarinpal-analytics-backend
├── frontend/ # ignored checkout of zarinpal-analytics-frontend ├── frontend/ # ignored checkout of zarinpal-analytics-frontend
├── docs/ ├── docs/
└── (deployment assets after specification approval) └── (deployment assets created through the authorized task list)
``` ```
The intended production hostname is `zarinpal.amiirkhl.ir`. The deployment shape will follow the proven Qlockify structure while being reviewed and hardened for this service; it is not a byte-for-byte copy. The production hostname is `zarinpal.amiirkhl.ir`. Caddy routes `/api/*` and `/healthz` to the Go API and every other path to the static frontend. PostgreSQL, Redis, the worker, and all one-off commands remain on the private Compose data network; the worker additionally joins a dedicated bridge network for outbound AI-provider access without joining the shared Caddy proxy network.
## Required server layout
```text
/home/ubuntu/zarinpal-analytics-deployment/
├── .env
├── backend/
│ └── .env
└── frontend/
└── .env
```
Clone the backend and frontend as ordinary ignored repositories, not submodules. Copy the three tracked examples to their ignored runtime names:
```bash
cp .env.example .env
cp backend/.env.production.example backend/.env
cp frontend/.env.production.example frontend/.env
chmod 600 .env backend/.env frontend/.env
```
Replace every `change-me` value. Generate URL-safe secrets with `openssl rand -hex 32`. The root file owns the PostgreSQL and Redis passwords plus the reviewed-calendar path and non-secret audit identities; Compose injects their connection settings into the Go containers. The frontend file contains public build-time switches only.
## First production start
Before adding/reloading the Caddy route, place the independently reviewed calendar JSON at `CALENDAR_FILE`, make it read-only, and confirm that its `sourceChecksum` is not a review placeholder. Then validate and build the stack, import the immutable dataset, bootstrap the reviewed calendar, create the demo account, and only then start the public upstreams:
```bash
docker compose config --quiet
docker compose build backend frontend
docker compose up -d postgres redis
docker compose --profile tools run --rm migrate
docker compose --profile import run --rm importer
docker compose --profile tools run --rm calendar
docker compose --profile tools run --rm admin
docker compose up -d backend worker frontend
docker compose ps
```
After every service reports healthy, validate and reload Caddy from the separate Caddy deployment. This order prevents the first public activation from serving an empty or partially imported database.
## Normal later deployments
Pull each repository manually, then run the guarded deploy script. It rejects dirty, non-`main`, or non-`origin/main` checkouts and never performs a fetch, reset, or checkout itself.
```bash
git pull --ff-only origin main
git -C backend pull --ff-only origin main
git -C frontend pull --ff-only origin main
bash scripts/deploy.sh
```
Normal deploys run migrations but never run the importer, calendar bootstrap, or admin command.
## Re-running one-off tools
`IMPORT_FILE` in the root `.env` is the absolute host path of the immutable `challenge_data.csv.gz`; `CALENDAR_FILE` is the absolute path of the reviewed JSON catalog. If recovery requires an idempotent data/catalog bootstrap or the demo account must be reset, run the applicable command explicitly:
```bash
docker compose --profile import run --rm importer
docker compose --profile tools run --rm calendar
docker compose --profile tools run --rm admin
```
All three commands are explicit one-offs. Calendar bootstrap resumes the same reviewed version safely and rejects placeholder checksums, conflicting metadata, retired versions, or replacement of a different active catalog. Future catalog rotation must use the separately reviewed import, approve, and activate workflow.
## Operations
```bash
docker compose ps
docker compose logs --tail=200 backend worker frontend postgres redis
docker compose restart backend worker frontend
docker compose down
```
Do not use `docker compose down -v` in production; `-v` deletes the PostgreSQL and Redis volumes. Caddy is managed from its own repository and must already provide the external `caddy_proxy` network.
## Documentation workflow ## Documentation workflow
@@ -24,3 +102,10 @@ The intended production hostname is `zarinpal.amiirkhl.ir`. The deployment shape
- Raw datasets and database state are never committed. - Raw datasets and database state are never committed.
- Every material change is a small Conventional Commit. - Every material change is a small Conventional Commit.
## Platform operations documents
- `docs/PLATFORM-OPERATIONS-SPEC.md` defines the production topology and is amended by ADR 0006 for the shared Caddy ingress.
- `docs/QLOCKIFY-ADAPTATION-CONTRACT.md` maps Qlockify's tracked deployment files and environment layout to the planned ZarinPal artifacts without requiring real server values during planning.
- `docs/runbooks/FIRST-SERVER-BOOTSTRAP.md` defines the safe first-host sequence without implementing a bootstrap script.
- `docs/runbooks/BACKUP-RESTORE.md` defines logical PostgreSQL backup, encryption/retention, guarded restore, and rehearsal.
- `tasks/plan.md` and `tasks/todo.md` hold the approved implementation plan and dependency-ordered Phase 4 tasks.

230
compose.yaml Normal file
View File

@@ -0,0 +1,230 @@
name: ${COMPOSE_PROJECT_NAME:-zarinpal_analytics}
x-logging: &bounded-logging
driver: json-file
options:
max-size: 10m
max-file: "3"
x-backend-runtime: &backend-runtime
image: zarinpal-analytics-backend:${IMAGE_TAG:-local}
build:
context: ./backend
dockerfile: ../docker/backend.Dockerfile
env_file:
- ./backend/.env
environment:
DATABASE_URL: postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable
REDIS_ADDR: redis:6379
REDIS_PASSWORD: ${REDIS_PASSWORD}
init: true
restart: unless-stopped
security_opt:
- no-new-privileges:true
logging: *bounded-logging
services:
postgres:
image: postgres:18.4-alpine3.24
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- postgres_data:/var/lib/postgresql
networks:
- data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\""]
interval: 10s
timeout: 3s
retries: 5
start_period: 30s
shm_size: 128mb
mem_limit: 512m
cpus: 0.75
pids_limit: 200
logging: *bounded-logging
redis:
image: redis:7.4.10-alpine3.21
restart: unless-stopped
environment:
REDIS_PASSWORD: ${REDIS_PASSWORD}
command:
- /bin/sh
- -ec
- |
chown redis:redis /data
exec /usr/bin/setpriv --reuid redis --regid redis --clear-groups \
/bin/sh -ec '
umask 077
printf "appendonly yes\nappendfsync everysec\nmaxmemory 192mb\nmaxmemory-policy noeviction\nrequirepass %s\n" "$$REDIS_PASSWORD" > /data/redis.conf
exec redis-server /data/redis.conf
'
volumes:
- redis_data:/data
networks:
- data
healthcheck:
test: ["CMD-SHELL", "REDISCLI_AUTH=\"$$REDIS_PASSWORD\" redis-cli ping | grep -q PONG"]
interval: 10s
timeout: 3s
retries: 5
start_period: 10s
mem_limit: 256m
cpus: 0.25
pids_limit: 100
security_opt:
- no-new-privileges:true
logging: *bounded-logging
backend:
<<: *backend-runtime
command: ["/app/api"]
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
expose:
- "8080"
networks:
data:
caddy_proxy:
aliases:
- zarinpal-backend
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/healthz"]
interval: 10s
timeout: 3s
retries: 5
start_period: 30s
stop_grace_period: 20s
mem_limit: 256m
cpus: 0.50
pids_limit: 150
worker:
<<: *backend-runtime
command: ["/app/worker"]
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
networks:
- data
- egress
dns:
- 1.1.1.1
- 8.8.8.8
healthcheck:
test: ["CMD-SHELL", "kill -0 1"]
interval: 10s
timeout: 3s
retries: 5
start_period: 30s
stop_grace_period: 70s
mem_limit: 512m
cpus: 0.75
pids_limit: 200
frontend:
image: zarinpal-analytics-frontend:${IMAGE_TAG:-local}
build:
context: ./frontend
dockerfile: ../docker/frontend.Dockerfile
init: true
restart: unless-stopped
expose:
- "8080"
networks:
caddy_proxy:
aliases:
- zarinpal-frontend
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/healthz"]
interval: 10s
timeout: 3s
retries: 5
start_period: 15s
security_opt:
- no-new-privileges:true
mem_limit: 96m
cpus: 0.25
pids_limit: 100
logging: *bounded-logging
migrate:
<<: *backend-runtime
command: ["/app/migrate"]
restart: "no"
depends_on:
postgres:
condition: service_healthy
networks:
- data
profiles: ["tools"]
importer:
<<: *backend-runtime
command: ["/app/importer"]
restart: "no"
depends_on:
postgres:
condition: service_healthy
volumes:
- ${IMPORT_FILE:-/srv/zarinpal-data/challenge_data.csv.gz}:/import/challenge_data.csv.gz:ro
networks:
- data
profiles: ["import"]
admin:
<<: *backend-runtime
command: ["/app/admin"]
restart: "no"
depends_on:
postgres:
condition: service_healthy
networks:
- data
profiles: ["tools"]
calendar:
<<: *backend-runtime
entrypoint: ["/app/calendar"]
command:
- -action
- bootstrap
- -file
- /calendar/reviewed.json
- -current-jalali-year
- "${CALENDAR_CURRENT_JALALI_YEAR:-0}"
- -reviewer
- "${CALENDAR_REVIEWER:-}"
- -actor
- "${CALENDAR_OPERATOR:-}"
restart: "no"
depends_on:
postgres:
condition: service_healthy
volumes:
- ${CALENDAR_FILE:-/srv/zarinpal-data/reviewed-calendar.json}:/calendar/reviewed.json:ro
networks:
- data
profiles: ["tools"]
networks:
data:
internal: true
egress:
driver: bridge
caddy_proxy:
external: true
name: ${CADDY_NETWORK:-caddy_proxy}
volumes:
postgres_data:
redis_data:

29
docker/backend.Dockerfile Normal file
View File

@@ -0,0 +1,29 @@
FROM golang:1.24-alpine AS builder
WORKDIR /src
RUN apk add --no-cache ca-certificates git
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/api ./cmd/api \
&& CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/worker ./cmd/worker \
&& CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/migrate ./cmd/migrate \
&& CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/importer ./cmd/importer \
&& CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/admin ./cmd/admin \
&& CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/calendar ./cmd/calendar
FROM alpine:3.22
RUN apk add --no-cache ca-certificates tzdata wget \
&& addgroup -S -g 10001 app \
&& adduser -S -D -H -u 10001 -G app app
WORKDIR /app
COPY --from=builder /out/ /app/
COPY --from=builder /src/migrations/ /app/migrations/
USER app:app
EXPOSE 8080
CMD ["/app/api"]

View File

@@ -0,0 +1,41 @@
FROM node:24-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginxinc/nginx-unprivileged:1.29-alpine
COPY --from=builder /app/build /usr/share/nginx/html
RUN <<'EOF' cat > /etc/nginx/conf.d/default.conf
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
server_tokens off;
location = /healthz {
access_log off;
default_type text/plain;
return 200 "ok\n";
}
location ~* \.(?:css|js|woff2|png|jpg|jpeg|gif|svg|webp|ico)$ {
try_files $uri =404;
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
}
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache";
}
}
EOF
EXPOSE 8080
HEALTHCHECK --interval=10s --timeout=3s --start-period=15s --retries=5 \
CMD wget -qO- http://127.0.0.1:8080/healthz || exit 1

View File

@@ -0,0 +1,53 @@
# Cross-Repository Specification and Plan Review
- Status: Approved — cross-repository implementation authorized 2026-08-20
- Date: 2026-08-20
- Scope: `zarinpal-analytics-backend`, `zarinpal-analytics-frontend`, and `zarinpal-analytics-deployment`
## Outcome
The approved product, data, analytics, identity, API, UX, and operations specifications are sufficiently resolved for implementation. The user approved all three repository plans/task lists and authorized Phase 4 implementation on 2026-08-20.
## Confirmed shared contracts
| Concern | Agreed contract | Owning repository |
|---|---|---|
| Dataset | One pinned archive is imported once into normalized PostgreSQL tables; raw challenge data is ignored by Git | Backend |
| Data lifetime | MVP payment facts are immutable after the sole completed import; future replacement or live writes require a new specification | Backend |
| Import activation | On the first server release only: start data services, migrate, import and reconcile, then activate public services | Deployment |
| Tenant authority | Merchant context comes only from the revocable server-side demo session, never from client-supplied merchant identifiers | Backend |
| Analytics | Day, Saturday–Friday week, Jalali month, and Persian season metrics follow catalogue `0.4.0` | Backend |
| Async delivery | Four independently completing sections use Redis/Asynq and authenticated SSE; Asynq is pinned pre-1.0 at `v0.26.0` | Backend |
| Cache identity | Redis keys include merchant, sole completed dataset snapshot, metric version, period, and section; PostgreSQL has no MVP invalidation triggers | Backend |
| Payment states | `Paid` means bank payment succeeded but merchant verification did not; `Verified` completed both steps; money success includes both where specified | Backend |
| Detail/export | Authorized details reconcile to aggregates; selected detail views support bounded Persian CSV export with the shared projection | Backend / Frontend |
| API | REST/SSE contract is approved as OpenAPI `1.0.0`; frontend transport types will be generated from the pinned artifact | Backend / Frontend |
| UI | SvelteKit 2, Svelte 5, TypeScript, Tailwind, RTL Persian digits, IRANYekanX Pro, responsive light/dark ZarinPal-inspired system | Frontend |
| Deployment | Three repositories, Docker Compose, Nginx, TLS, environment files, guarded operations, encrypted PostgreSQL backup/restore | Deployment |
## Implementation plan map
1. Backend foundation: Go workspace, normalized Ent/Atlas schema, guarded one-time importer, validation/promotion/recovery, and deterministic demo accounts.
2. Backend services: bounded demo authentication, metric query kernel, Redis/Asynq orchestration, details, exports, and provider integration.
3. Backend HTTP surface: OpenAPI conformance, middleware, auth/period/run/SSE/retry/detail/CSV endpoints, audit, and hardening.
4. Frontend: foundation and tokens, generated transport, shell/auth, dashboard sections, partial SSE states, detail/export workflows, responsive/accessibility/theme verification.
5. Deployment: adapt Qlockify's Compose/Nginx/environment/manual-release/backup operations, integrate first-start ordering, verify locally with safe placeholders, then perform the separately authorized server release with real runtime values.
Backend provider work and frontend visual-system work can proceed in parallel after separate implementation authorization. Deployment scaffolding can also proceed in parallel once command/build contracts are stable; production bootstrap and cutover remain sequential gates.
## Deferred interface details (not planning blockers)
These are build-time choices, not unresolved product requirements:
- exact pinned OpenAPI generation/validation tools and repository command wrappers;
- exact frontend/backend build and health commands after their scaffolds exist (frontend history fallback is already restricted to non-API routes);
- exact backend/frontend command and environment-variable names after their scaffolds exist;
- the hosting path for human-readable API problem pages.
Server IP/DNS state, Git credentials, ACME email, ParsPack values, operator identity, and alert destinations are runtime inputs supplied when their operations are performed. They do not block implementation planning or local placeholder-based validation. Deployment uses no CI/CD pipeline, runner, or registry.
No secrets, raw dataset, generated database, credentials, certificates, backups, or runtime volumes may be committed.
## Authorization gate
The user approved these plans and explicitly authorized implementation on 2026-08-20. Implementation must follow each repository's dependency-ordered task list and review checkpoints. Remote pushes remain forbidden until the user separately requests a push.

View File

@@ -0,0 +1,9 @@
# Growth Coach Scheduler Contract
- `cmd/scheduler` یک سرویس خصوصی و مستقل از API/worker است و endpoint عمومی ندارد.
- هر ساعت بیدار می‌شود؛ قبل از ساعت ۰۷:۰۰ تهران job روزانه نمی‌سازد.
- گزارش روزانه برای هر پروفایل جاری، برنامه هفتگی در شنبه و برنامه ماهانه در روز اول ماه جلالی enqueue می‌شود.
- fingerprint یکتای PostgreSQL از merchant، terminal، cadence، شروع دوره و profile version ساخته می‌شود؛ اجرای هم‌زمان scheduler job تکراری ایجاد نمی‌کند.
- scheduler فقط enqueue می‌کند. generation در worker انجام می‌شود و هر render صرفاً state ذخیره‌شده را می‌خواند.
- rollback با خاموش‌کردن `AI_COACH_ENABLED` انجام می‌شود؛ داده‌های profile، plan و «قدم‌های زرین» حفظ می‌شوند.
- signals عملیاتی: queue age، generation latency، provider/validation failure، token/cost، stale plan و evaluation lag. prompt و متن profile نباید log شوند.

46
docs/LOCAL-DEVELOPMENT.md Normal file
View File

@@ -0,0 +1,46 @@
# Local development without Docker Compose
This Windows-only helper runs the API, analytics worker, growth-coach scheduler, and frontend directly from the three sibling repositories. PostgreSQL and Redis must already be reachable locally. It does not create databases, run migrations/imports, generate credentials, edit service-owned environment files, or start Docker.
## Prerequisites
- Go and npm are on `PATH`.
- PostgreSQL is reachable at `127.0.0.1:5432`.
- Redis is reachable at `127.0.0.1:6379`.
- The backend has `cmd/api`, `cmd/worker`, and `cmd/scheduler`, plus a protected backend-owned `.env`.
- The frontend has a `package.json` `dev` script and its frontend-owned local environment file.
- Dependencies, migrations, the one-time importer, and demo merchant creation have already completed through their owning repositories.
The helper only reports variable names or file paths when validation fails. It never prints resolved environment values. Runtime process metadata and logs are written under ignored `.local/`.
## Commands
Run these from the deployment repository in PowerShell:
```powershell
.\scripts\local-dev.ps1 check
.\scripts\local-dev.ps1 start
.\scripts\local-dev.ps1 status
.\scripts\local-dev.ps1 stop
```
The default sibling locations are `../zarinpal-analytics-backend` and `../zarinpal-analytics-frontend`. Override them with `-BackendPath` and `-FrontendPath` when necessary.
`stop` terminates only the process trees whose PID and start time match the ignored state file. A stale or reused PID is not terminated.
## Verification
```powershell
pwsh -NoProfile -File .\tests\local-dev.tests.ps1
pwsh -NoProfile -File .\scripts\local-dev.ps1 check
```
After `start`, open the frontend URL declared by the frontend development server. Inspect `.local/logs/*.err.log` if a process exits during or after startup.
## AI growth analyst
The AI growth analyst is controlled by service-owned environment files; no AvalAI credential belongs in this deployment repository or in the frontend.
For local verification, set `AI_COACH_ENABLED=true`, `AI_PAGE_INSIGHTS_ENABLED=true`, and `AI_GROWTH_ENABLED=true` in the ignored backend `.env`, and `PUBLIC_AI_GROWTH_ENABLED=true` in the ignored frontend `.env`. With `AVALAI_API_KEY` empty, no generation is misrepresented as a fresh AI response.
The backend `.env.example` is authoritative for the complete contract. The production operator must explicitly provide `AVALAI_API_KEY`, `AVALAI_MODEL`, `AI_DAILY_REQUEST_BUDGET`, and `AI_DAILY_TOKEN_BUDGET` before enabling the feature. `AVALAI_BASE_URL`, `AI_MAX_CONCURRENT`, `AI_CREATE_PER_MINUTE`, and `AI_UNCACHED_SCOPES_PER_DAY` remain configurable safety limits. Never place `AVALAI_API_KEY` in a public frontend variable.

View File

@@ -0,0 +1,523 @@
# Platform Operations Capability Spec
- Status: Approved specification
- Implementation authorization: Granted by user on 2026-08-20
- Capability: Platform operations
- Depends on: all deployable backend and frontend capability contracts
- Last updated: 2026-08-20
> **2026-08-22 ingress amendment:** ADR 0006 supersedes the dedicated Nginx/Certbot edge described below. The host's existing Caddy deployment owns ports 80/443, TLS, canonical-host routing, security headers, API/SSE proxying, and frontend proxying. This project publishes no ports and attaches only `backend` and `frontend` to the external `caddy_proxy` network. All private-data, manual-release, migration/import, secret, health, backup, and recovery requirements remain in force.
## Confirmed intent
Deploy `zarinpal.amiirkhl.ir` on one Ubuntu server using the proven Qlockify operating model: three nested Git repositories, Docker Compose builds performed on the server, Nginx as the only public entry point, automatic Let's Encrypt TLS, PostgreSQL 18, Redis 7 Alpine, a Go API and event-driven worker, and a static frontend.
Production deployment is manual. The operator pulls the latest `main` from all three repositories, records the resulting commit hashes, builds locally on the server, runs migrations and health checks, and records the release. Rollback checks out the previously recorded hashes and rebuilds. There is no CI/CD pipeline, runner, private image registry, or cross-repository automation.
MVP includes health checks, structured and bounded logs, safe startup, explicit migration/import separation, rollback, and encrypted daily PostgreSQL backup/restore adapted with minimal changes from Qlockify. Prometheus and Grafana are a required follow-up phase after the core project is ready and before AI work begins.
## Objective
Provide a production-shaped hackathon platform that a trusted operator can understand and recover without hidden automation. A clean server can be prepared, the exact source revisions can be deployed, only approved traffic is publicly exposed, database changes and one-time data import are deliberate, failures leave useful evidence, and PostgreSQL can be restored from an encrypted off-host backup.
## Scope
### MVP scope
- Qlockify-style nested repository layout and manual server-side deployment
- Docker Compose service topology and internal networks
- Nginx canonical routing, security headers, TLS termination, and SSE behavior
- PostgreSQL 18, Redis 7 Alpine, backend API, frontend, and worker boundaries
- Liveness/readiness, startup ordering, migration, shutdown, and resource limits
- Explicit one-time CSV import and demo-credential administrative jobs
- Environment, secret, volume, and host exposure policy
- Manual release records, health verification, and Git-hash rollback
- Structured container logs, log rotation, Docker health status, and operator checks
- Daily encrypted PostgreSQL backup, S3-compatible upload, retention, and guarded restore adapted from Qlockify
- Safe first-server bootstrap
- Normal frontend build consumption of the tracked licensed font assets
### Required post-MVP, pre-AI phase
- Prometheus metrics collection
- Grafana dashboards
- Private exporters for host/container/PostgreSQL/Redis signals
- Symptom-based alerts and alert delivery testing
This observability phase must be complete before the AI capability starts. It does not block the first working MVP deployment.
### Out of scope
- CI/CD pipelines, hosted/self-hosted runners, automated deploy triggers, and private image registries
- Kubernetes, multi-server failover, multi-region operation, or zero-downtime claims
- Scheduled or watched CSV ingestion
- Redis backup or treating Redis as a source of truth
- Public database, Redis, Docker, worker, metrics, debug, or administration endpoints
- Backend/frontend application implementation in this repository
- Private font injection or font copies in the deployment repository
- AI implementation
## Approved assumptions and decisions
1. The target is a new dedicated Ubuntu 24.04 LTS server with 2 CPU cores, 2 GB RAM, Docker already installed, user `ubuntu`, and a bootstrap-created 2 GB swap file.
2. The trusted `ubuntu` user belongs to the `docker` group and runs Docker Compose. Docker-group membership is root-equivalent, so access to this account is restricted to trusted operators.
3. The deployment repository contains ignored nested checkouts of the backend and frontend repositories, matching ADR 0001 and Qlockify.
4. The operator manually runs `git pull --ff-only origin main` in all three repositories and enters HTTPS credentials interactively. The deployment command performs no pull or credential handling; it verifies and records the three commits before building.
5. Application images are built on the production server from those recorded checkouts. They are not published to a registry.
6. Rollback checks out the previous recorded hashes and rebuilds. Database rollback remains constrained by migration compatibility.
7. Nginx obtains and renews a Let's Encrypt certificate automatically through Certbot using HTTP-01.
8. Infrastructure images are pinned to `postgres:18.4-alpine3.24`, `redis:7.4.10-alpine3.21`, `nginx:1.29.8-alpine3.23`, and `certbot/certbot:v5.7.0`.
9. Redis uses a named volume, AOF `everysec`, and `noeviction` for routine restart continuity. It remains disposable cache/queue/coordination state and is excluded from backup; PostgreSQL remains authoritative.
10. Daily encrypted PostgreSQL backups and tested restores are MVP requirements.
11. Qlockify backup/restore/upload scripts are the implementation reference and will receive only necessary project path, service, credential, and safety changes.
12. Production `.env` files, secrets, TLS keys, Redis data, raw CSV, credentials, logs, and font assets are excluded from database backup packages.
13. Prometheus/Grafana are required after MVP readiness and before AI implementation. MVP readiness requires application flows, tenant isolation, migration/import, TLS, manual health/log checks, rollback, encrypted backup, and clean restore to pass.
14. Frontend commit `64f5ac2` owns `static/fonts/IRANYekanXVFaNumVF.woff2` and `static/fonts/FontLicense.txt`; deployment uses the normal frontend build context without another asset path.
Any change to these decisions requires a spec revision before implementation.
## Repository and server layout
```text
/home/ubuntu/zarinpal-analytics-deployment/
├── .git/ deployment repository
├── backend/ ignored checkout of backend repository
│ └── .git/
├── frontend/ ignored checkout of frontend repository
│ └── .git/
├── docs/
├── backups/ ignored runtime output
└── deployment assets only after explicit implementation authorization
```
Each checkout keeps its own Git history and remote. Deployment operations verify the expected repository, branch, remote, clean state, and resolved absolute path before pulling, checking out, or building.
Production configuration and secrets live outside public Git. Runtime volumes, backups, certificate data, raw CSV, and generated credentials remain ignored.
## Runtime architecture
```text
Internet
|
| TCP 80/443 only
v
Nginx + Certbot
|
+------------------------+
| private app network |
v v
frontend static server backend API
|
+---------+---------+
| private data net |
v v
PostgreSQL <------- Redis
^ ^
| |
one-off jobs worker
migrate / import / admin
```
Nginx is the only public application entry point. The worker is event-driven and never scheduled. It uses Asynq pinned to `v0.26.0`, explicitly a pre-1.0 dependency. Redis loss causes controlled job/cache reconciliation and recomputation, not loss of authoritative payment or session data.
## Docker Compose topology
| Service | Responsibility | Networks | State | Host exposure |
|---|---|---|---|---|
| `nginx` | TLS, canonical routing, limits, static/API/SSE proxy | `edge`, `app` | certificate mounts only | 80/443 |
| `certbot` | Initial issuance and renewal | `edge` | ACME/certificate volume | none |
| `frontend` | Serve static SvelteKit build | `app` | none | none |
| `backend` | Go API, sessions, authorization, analytics control | `app`, `data` | none | none |
| `worker` | On-demand Asynq analytics sections | `data` | none | none |
| `postgres` | Authoritative database | `data` | `postgres_data` | none |
| `redis` | Queue, cache, rate-limit, replay coordination | `data` | named AOF volume, excluded from backup | none |
| `migrate` | Versioned schema migration | `data` | none | one-off only |
| `import` | One-time CSV bootstrap/recovery | `data` | exact read-only input bind | one-off profile only |
| `calendar` | Reviewed catalog bootstrap with audited state transitions | `data` | reviewed read-only JSON bind | one-off profile only |
| `admin` | Demo credentials and approved maintenance | `data` | explicit output bind if requested | one-off profile only |
Post-MVP adds `prometheus`, `grafana`, and approved exporters on a separate private monitoring network. Grafana binds only to loopback/private operator access unless a later ADR approves a protected public route.
### Image and build policy
- PostgreSQL, Redis, Nginx, and Certbot use the exact approved tags above; no `latest` tag.
- Backend and worker build from the same recorded backend commit and deployment Dockerfile/context contract.
- Frontend builds from the recorded frontend commit and normal build context, including its tracked font/license inputs.
- The deployment script records resulting local image identifiers and build timestamps for diagnosis, but Git commit hashes are the reproducible source reference.
- Build failure stops before replacing healthy running services.
### Network and container policy
- Only Nginx publishes ports 80 and 443.
- PostgreSQL, Redis, backend, frontend, worker, and later monitoring services do not publish internet-facing ports.
- Frontend and Nginx cannot connect directly to PostgreSQL or Redis.
- Containers run as non-root where their official/application image supports it, drop unnecessary capabilities, set `no-new-privileges`, and use read-only filesystems where practical.
- CPU, memory, PID, database-pool, and log limits are explicit. Initial values are validated against the actual server and import/load tests.
- Docker logs use bounded rotation so a noisy service cannot fill the server disk.
Initial runtime limits for the 2-GB server are:
| Service | Memory limit |
|---|---:|
| PostgreSQL | 512 MB |
| Redis | 256 MB container limit; 192 MB `maxmemory` target to leave process/AOF overhead |
| Backend API | 256 MB |
| Worker | 512 MB |
| Frontend | 96 MB |
| Nginx | 96 MB |
Certbot is one-shot/periodic rather than continuously memory-resident. Worker concurrency remains 16 globally and 4 per merchant. If measured load exceeds this server, upgrade server capacity rather than silently lowering the approved concurrency.
## Manual deployment and release records
### Normal deployment
1. Confirm exact server, deployment root, environment, available disk, recent backup, Docker health, and operator identity.
2. Acquire a deployment lock so backup/restore/import/deploy operations cannot conflict unsafely.
3. Before running the deployment command, the operator manually executes `git pull --ff-only origin main` in each repository and enters HTTPS credentials interactively; no Git credential is stored by deployment.
4. Verify all three repositories exist at their exact expected paths, use the approved HTTPS remotes, are on `main`, match `origin/main`, and contain no unexplained local changes.
5. Record the deployment, backend, and frontend commit hashes under the ignored `releases/` directory before building.
6. Validate required configuration/secret files without printing values.
7. Render and validate Docker Compose configuration.
8. Build candidate application/Nginx images on the server.
9. Start/verify PostgreSQL and Redis.
10. Run the dedicated migration service once and require success.
11. Replace backend/worker, then frontend, then validate/reload Nginx.
12. Run public and private health/smoke checks and observe for 15 minutes.
13. Write a non-secret release record and mark it active only after checks pass.
The future deployment script adapts Qlockify's manual `deploy.sh` structure but must not use unconditional `git reset --hard`; a dirty or divergent checkout stops and asks the operator to resolve it.
### Release record
Each server-side release record contains:
- release id and UTC timestamp
- deployment, backend, and frontend Git commit hashes
- Git branch and remote identity for each checkout
- resulting local image ids/tags
- migration version
- non-secret configuration checksum
- operator identity
- preflight backup id/age
- pre/post health results
- previous release id
- outcome and rollback reason if applicable
It contains no secret values, `.env` content, raw CSV path/content, credentials, license text, font copy, database rows, or TLS private material.
### Rollback
- Select the previous known-good release record explicitly.
- Verify the three target hashes exist, repository paths/remotes are correct, and working trees are clean.
- Check out the previous deployment/backend/frontend hashes and rebuild locally.
- Do not automatically reverse database migrations. Normal migrations use expand/contract compatibility so the previous application can run against the expanded schema.
- Run the same health/smoke checks and record rollback as a new operational event.
- Target application-only rollback time is 15 minutes under normal Git/build conditions.
## Startup, migration, import, and shutdown
### Startup order
1. PostgreSQL and Redis start and become healthy.
2. The one-off migration completes successfully.
3. Backend and worker start and become ready.
4. Frontend becomes healthy.
5. Nginx routes production traffic only when required upstreams are ready.
Compose health/dependency conditions prevent common startup races, but the manual deployment operation still checks every step explicitly.
### Migration contract
- Application startup never performs migrations or automatic schema creation.
- Migrations run from the recorded candidate backend revision under one migration lock.
- Changes are backward-compatible through expand/deploy/contract sequencing.
- Destructive or long-locking migrations require explicit approval, a fresh verified backup, measured evidence on production-shaped data, and a separate recovery plan.
- Migration failure stops deployment before candidate backend/worker replacement.
- Database down-migration is never automatic.
### One-time CSV import
- Import is an explicit operator command/profile, never normal startup, cron, a scheduler, watcher, or deploy trigger.
- On the first server release only, tracked `scripts/first-start.sh` orchestrates PostgreSQL/Redis readiness, the one-off migration, the importer profile, reconciliation, reviewed calendar bootstrap, and then public application startup. Later ordinary deploys/restarts never invoke it.
- The operator provides an exact resolved path outside Git; only that file is mounted read-only.
- Import cannot overlap restore, backup, or another import and starts only after migration succeeds.
- The backend importer pins the approved checksum/schema/filename/counts, enforces resource caps, streams data, reconciles counts, removes temporary raw staging, and records incomplete failure safely according to the approved backend import specifications.
- An identical completed import is idempotent and does not duplicate business rows.
- Import failure prevents the first public application activation. Recovery restarts from the immutable archive; it never resumes guessed partial CSV/database state.
- Demo credential generation is a separate one-time `admin` job whose plaintext output never enters Compose logs, Git, backups, or shell history by default.
### Graceful shutdown
- Nginx stops accepting new traffic before backend termination.
- Backend drains within a bounded timeout.
- Worker stops fetching new jobs; active attempts remain bounded by their existing 60-second timeout and retry rules.
- PostgreSQL stops last. Forced termination is recorded as a deployment failure.
## Health and readiness
Health responses are generic and never reveal versions, addresses, schema details, credentials, tenant data, or stack traces.
| Component | Liveness | Readiness |
|---|---|---|
| Nginx | process/config responds | TLS virtual host can serve frontend and proxy backend |
| Frontend | local static sentinel | index and required built assets/font are readable |
| Backend | process responds without dependency calls | expected migration, PostgreSQL, and Redis capabilities are available |
| Worker | process probe | PostgreSQL/Redis available, queue consumer registered, heartbeat current |
| PostgreSQL | process | authenticated `pg_isready` for configured database/user |
| Redis | process | authenticated `PING` and required queue/cache commands |
Initial health timing target is 10-second interval, 3-second timeout, 30-second start period, and five failures before unhealthy, revised only from measured startup evidence. Dependency failure changes readiness but does not deliberately create liveness restart storms.
## Nginx, domain, SSE, and TLS
### Routing
- Port 80 serves `/.well-known/acme-challenge/` and redirects everything else to `https://zarinpal.amiirkhl.ir$request_uri`.
- The certificate and application serve only `zarinpal.amiirkhl.ir`; there is no `www` or alternate domain. Unknown hosts are rejected by a default server.
- `/api/` proxies only to the backend with host, scheme, safe client-address chain, and request id.
- `/` serves/proxies the frontend; SPA fallback never captures API errors.
- Fingerprinted assets receive long immutable caching; HTML/mutable manifests revalidate.
- Public metrics, debug, profiling, database, Redis, worker, Docker, or administration routes do not exist.
### SSE
- The approved exact analytics SSE route gets a dedicated location.
- Upstream HTTP/1.1 is used; `Cookie`, `Last-Event-ID`, request id, host, scheme, and client-forwarding headers are preserved.
- Proxy buffering, caching, compression, and transformations are disabled for the route.
- Read timeout is safely above the 15-second heartbeat; initial target is 75 seconds.
- Every connection/replay is authenticated by the backend. Nginx does not bypass session or tenant checks.
### TLS
- Certbot obtains the certificate through HTTP-01 after DNS and port checks.
- Staging issuance/renewal is tested before production issuance.
- Nginx may temporarily use a self-signed certificate only during isolated first bootstrap; it is never production-ready.
- TLS 1.2/1.3 only, with reviewed security headers.
- HSTS is enabled only after production issuance and renewal are proven. Preload requires separate approval.
- Certificate validity/expiry is checked daily in MVP operations; Prometheus alerting is added in the required post-MVP observability phase.
## Configuration and secrets
- A committed safe example names required variables and contains placeholders only.
- Follow the Qlockify environment layout: deployment `.env` in the deployment root plus backend/frontend `.env` files in their nested repository roots. All are Git-ignored, mode 0600, and excluded from backups.
- Secrets are mounted only into the services that need them and never passed in command-line arguments or embedded in images.
- Frontend receives no production secret.
- Compose validation and diagnostics must not print resolved secret values.
- Backup object-store credentials and encryption passphrase are read from the protected deployment `.env`, following the Qlockify script interface with ZarinPal-specific ParsPack bucket/prefix and separate credentials.
- If a secret reaches Git/logs, rotate/revoke it first, then clean history/evidence as a separate incident action.
### Licensed font
Frontend commit `64f5ac2` tracks `static/fonts/IRANYekanXVFaNumVF.woff2` and `static/fonts/FontLicense.txt`. The normal frontend server build includes them. Deployment adds no fetch, injection, credential, duplicate binary, or license copy. Smoke checks verify the emitted font resource and content type without reproducing license text.
## MVP logging and operator checks
### Logs
- Backend and worker emit structured JSON to stdout with UTC timestamp, level, service, release id, request id, analytics run/section/job ids where applicable, safe error class, and duration.
- Nginx access logs include request id, safe route, status, bytes, request/upstream duration, and upstream status.
- Never log passwords, hashes, cookies, raw tokens, payer-card keys, raw CSV rows, database/Redis URLs, backup keys, `.env` contents, or full merchant payloads.
- Docker log rotation is explicit; initial target is 10 MiB per file and three files per container, adjusted from incident/disk evidence.
- Production debug logging is off by default and time-bounded when enabled.
### MVP manual checks
The operator can check:
- Compose health and restart counts
- Nginx/backend/worker/PostgreSQL/Redis logs
- public TLS/canonical redirect and generic health
- disk space/inodes and database/backup size
- Redis memory and queue health
- certificate days remaining and renewal result
- last verified backup age and retention count
- current/previous release records
An operator checklist/runbook provides exact commands after implementation approval.
## Required post-MVP observability phase
Before AI implementation begins, add private Prometheus/Grafana and approved exporters/instrumentation for:
- HTTP RED metrics with bounded route/status labels and latency histograms
- worker queue depth/oldest age, section duration, retries/failures, concurrency, cache hit/miss, and SSE connection/replay behavior
- PostgreSQL availability, connections, locks, query duration aggregates, database/volume size
- Redis availability, memory, eviction, persistence, connections, and queue state
- host/container CPU, memory, filesystem/inodes, OOM/restarts, and network
- backup success/age/duration/size/remote verification and restore-rehearsal age
- TLS expiry and renewal status
Grafana and Prometheus remain private. Alerts are symptom-based, actionable, link to runbooks, and use only page/ticket severity. Merchant/user/request/card ids never become metric labels.
Initial targets to validate:
| Objective | Target |
|---|---|
| Public availability | 99.5% over rolling 7 days excluding approved maintenance |
| Unexpected API 5xx | under 1% over 5 minutes under acceptance traffic |
| Non-analytics API latency | p95 under 500 ms under acceptance load |
| Analytics start | p95 under 1 second |
| SSE | connection p95 under 2 seconds; healthy heartbeat gap at most 20 seconds |
| Section jobs | queue delay p95 under 5 seconds; every attempt at most 60 seconds |
| Capacity | at least 20% and 10 GiB free on critical filesystems |
| Backup | verified encrypted off-host backup every 24 hours |
| Restore | clean rehearsal within two hours |
| TLS | valid automatically renewable certificate |
## Backup and restore
### Qlockify adaptation boundary
Reuse and minimally adapt these read-only reference scripts after implementation approval:
- `qlockify-deployment/scripts/backup.sh`
- `qlockify-deployment/scripts/backup-upload-s3.sh`
- `qlockify-deployment/scripts/restore.sh`
- `qlockify-deployment/scripts/restore-from-s3.sh`
Necessary changes include deployment root, archive/prefix names, Compose service names, protected credentials, PostgreSQL-only contents, manifest/checksums, exact target validation, and production confirmation guards. The core `pg_dump`/archive, OpenSSL encryption, rclone S3-compatible upload, local/remote retention, download/decrypt, and restore flow remains recognizable.
### Backup contract
- Daily at `02:00 UTC`, create a PostgreSQL plain-SQL logical dump using the matching PostgreSQL 18 client, following Qlockify's `pg_dump` flow.
- Backup package contains database archive plus non-secret manifest/checksums only.
- Encrypt before durable local/remote storage using Qlockify's OpenSSL AES-256-CBC with PBKDF2 and 200,000 iterations; pair it with recorded SHA-256 checksums because CBC encryption is not itself authenticated.
- Upload to private S3-compatible storage over TLS and verify the remote object.
- Retain the latest three verified encrypted local backups and seven verified encrypted remote backups.
- Delete stale backups only after a new remote backup is verified; never delete the last known-good copy.
- Exclude `.env`, secrets, Redis, TLS keys, raw CSV, credentials, logs, monitoring state, font binary, and `FontLicense.txt`.
- A dump is successful only after plain-SQL header/size/checksum validation, package-content inspection, encryption, upload, remote verification, and manifest completion.
### Restore contract
- Default restore target is a new empty non-production database; plain SQL is restored with `psql -X --set ON_ERROR_STOP=on` and a reviewed transaction strategy.
- Validate explicit backup id, checksums, manifest, package contents, plain-SQL header, PostgreSQL compatibility, free space, and exact resolved destination before mutation.
- Production restore additionally requires environment, host, database, backup id, typed confirmation, maintenance mode, exclusive lock, and named operator.
- Never pipe an unchecked dump into the active database or restore over a running destination in place.
- Restore into an empty database/cluster, validate counts/invariants/tenant isolation/login/cold Redis analytics/SSE, then switch/reopen traffic.
- Rehearse monthly and before destructive migrations; record achieved RPO/RTO.
## Safe first-server bootstrap
1. Record provider/instance, OS, network, capacity, recovery console, operators, DNS, and rollback window.
2. Prove two key-only operator logins and provider console recovery before disabling root/password SSH.
3. Configure default-deny firewall: operator-allowlisted SSH plus public 80/443 only.
4. Patch the OS, enable time sync/security updates/log rotation/disk monitoring, and reboot-test access.
5. Verify the preinstalled Docker Engine/Compose installation, record its source/version, and ensure only the trusted `ubuntu` user has the required Docker-group access.
6. Clone the deployment repository and backend/frontend nested repositories into exact validated paths.
7. Create protected configuration/secret paths outside Git and validate permissions.
8. Validate Compose and local builds without starting public traffic.
9. Point DNS, test ACME staging, issue production TLS, and verify canonical routing.
10. Run tracked `scripts/first-start.sh`: start data services, migrate, import/reconcile the pinned archive, then start API/worker/frontend/Nginx and run health checks.
11. Run separate demo credential generation, authenticate as at least two merchants, and verify tenant isolation.
12. Complete encrypted remote backup and clean restore rehearsal before handoff.
## Threats and failure behavior
| Failure/threat | Required behavior |
|---|---|
| Wrong server/path/repository | Stop before Git, build, volume, or database mutation |
| Dirty/diverged checkout | Stop and ask operator; never unconditional hard reset |
| Pull/build failure | Keep current containers/release; record evidence |
| Missing/malformed secret/config | Fail before mutation without printing value |
| PostgreSQL/Redis unhealthy | Do not start/route dependent candidate services |
| Migration failure | Stop release; no candidate backend/worker; no automatic down migration |
| Import failure | Mark incomplete; keep normal startup independent; no scheduling |
| Frontend/font failure | Keep previous frontend; no deployment-side asset workaround |
| Worker/Redis loss | Preserve PostgreSQL; reconcile/recompute idempotently |
| Certificate failure | Production readiness fails; do not serve plaintext application traffic |
| Backup/upload failure | Preserve all prior verified copies and alert/operator-block destructive work |
| Restore target mismatch | Abort before database mutation |
| Disk/log exhaustion risk | Bounded logs/resources and stop deploy before critical free-space floor |
| Spoofed operator | Key-only restricted SSH, named release record, minimal Docker access |
| Secret disclosure | File permissions, log allowlists, no env in backup; rotate first if exposed |
## Always / Ask / Never boundaries
### Always
- Verify exact server, paths, repository remotes/branches/cleanliness, and record all three commit hashes before build.
- Build from the recorded checkouts and record the resulting release.
- Keep Nginx as the only public application entry point and use valid HTTPS.
- Separate migrations, API startup, worker startup, CSV import, and credential generation.
- Keep Asynq pinned to pre-1.0 `v0.26.0` until compatibility/recovery tests approve a change.
- Bound resources, timeouts, retries, logs, and backup retention.
- Back up PostgreSQL encrypted off-host and rehearse restore.
- Use the ordinary frontend build context for the tracked font/license.
### Ask first
- Expose a new port, hostname, admin/debug/docs/metrics route, or third-party endpoint.
- Change PostgreSQL/Redis major version, backup provider/encryption, retention, RPO/RTO, or restore behavior.
- Run a destructive/non-compatible migration, production restore, or replacement import.
- Change the manual latest-`main` deployment rule, repository topology, deploy-user privilege, resource limits, exact image tags, or TLS/HSTS scope.
- Add Prometheus/Grafana before MVP is ready or start AI before the observability phase is accepted.
- Change frontend ownership/location of the licensed assets.
### Never
- Add a CI/CD pipeline, runner, automated deployment trigger, or private image registry without a new approved decision.
- Push from an agent session or rewrite/discard user history.
- Publish PostgreSQL, Redis, backend, worker, monitoring, or Docker daemon ports to the internet.
- Use unconditional `git reset --hard` on a dirty/diverged server checkout.
- Run migration or CSV import inside API startup; schedule/watch/poll the CSV.
- Include `.env`, secrets, TLS keys, Redis, raw CSV, credentials, logs, or font/license files in database backup.
- Log credentials, raw tokens/cookies, payer-card keys, raw payment rows, or secret connection values.
- Automatically run down migrations or restore over production without every confirmation guard.
- Add another font delivery/injection path or copy the assets into deployment Git.
- Disable health/security/backup gates to finish a deployment.
## Measurable acceptance criteria
### Manual deployment and rollback
- All three nested repositories resolve to approved remotes, latest `origin/main`, clean working trees, and recorded commit hashes before build.
- An induced pull/build failure leaves the active stack unchanged.
- A release record maps the running stack to three Git hashes, local image ids, migration version, operator, backup, and health evidence without secrets.
- Selecting the prior release hashes, rebuilding, and running checks completes application rollback within 15 minutes under normal conditions.
- No CI/CD workflow, runner configuration, registry dependency, or automated deployment trigger exists.
### Topology, routing, and lifecycle
- External scanning finds only approved SSH plus TCP 80/443; all internal services are unreachable directly.
- HTTP redirects to `https://zarinpal.amiirkhl.ir`; unknown hosts are rejected; TLS issuance and renewal pass.
- `/api/` reaches only backend, application routes reach frontend, and SSE events/heartbeats are delivered without buffering.
- Clean startup health-gates PostgreSQL/Redis, runs migration once, then starts API/worker/frontend/Nginx.
- Normal startup/deploy never runs import/admin. Identical explicit import does not duplicate business rows.
- Redis deletion causes safe recomputation without loss of PostgreSQL payment/session data.
- Required font URL returns 200 with correct type, while deployment Git contains no font/license copies.
### Security and recovery
- Real secrets remain outside Git with restrictive permissions and do not appear in Compose output/logs.
- Docker logs are structured/bounded and an induced backend failure is traceable by request/release id without sensitive fields.
- Daily backup contains only PostgreSQL archive plus non-secret manifest/checksums, is encrypted, remotely verified, and retained three local/seven remote.
- No excluded `.env`, secret, Redis, TLS, CSV, credential, log, monitoring, font, or license asset appears in the backup.
- Clean restore completes within two hours and passes schema/count/tenant/login/cold-cache/worker/SSE validation.
- Every missing production restore confirmation factor aborts before mutation.
### Phase ordering
- MVP can be operated through health, logs, release records, disk/certificate/backup checks without Prometheus/Grafana.
- Prometheus/Grafana/exporters/dashboards/alerts are implemented and accepted after MVP readiness and before any AI implementation begins.
## Implementation readiness
No stakeholder-level Platform Operations decisions or real server values are required to plan or implement the deployment artifacts. Follow `QLOCKIFY-ADAPTATION-CONTRACT.md` for the exact source-file mapping and root/backend/frontend environment ownership.
Tracked examples use blank secret placeholders and safe public defaults. The real server IP/DNS state, ParsPack values, operator identity, and secret values are runtime inputs supplied only when the relevant first-server or production operation is performed. Backend/frontend-owned command and variable names are consumed from their implementation contracts during the corresponding deployment slice; they do not reopen the approved topology or block earlier independent slices.
Implementation remains gated by explicit stakeholder authorization. If a discovered interface would change an approved behavior rather than fill in a service-owned command/value, amend this spec first.
## References
- Docker Compose application model: <https://docs.docker.com/compose/intro/compose-application-model/>
- Docker Compose startup order and health conditions: <https://docs.docker.com/compose/how-tos/startup-order/>
- Docker logging configuration: <https://docs.docker.com/engine/logging/configure/>
- Nginx proxy buffering/timeouts: <https://nginx.org/en/docs/http/ngx_http_proxy_module.html>
- Nginx TLS module: <https://nginx.org/en/docs/http/ngx_http_ssl_module.html>
- PostgreSQL 18 backup/restore: <https://www.postgresql.org/docs/current/backup.html>
- PostgreSQL dump/restore guidance: <https://www.postgresql.org/docs/current/backup-dump.html>
- Local read-only Qlockify reference: `D:/Programing/Scripts/Qlockify/qlockify-deployment`
- Local adaptation map: `docs/QLOCKIFY-ADAPTATION-CONTRACT.md`

View File

@@ -0,0 +1,136 @@
# Qlockify deployment adaptation contract
- Status: Approved planning baseline
- Source: `D:/Programing/Scripts/Qlockify/qlockify-deployment` (read-only)
> **2026-08-22 ingress amendment:** ADR 0006 replaces the copied Nginx/Certbot edge with the host's existing Caddy deployment and external `caddy_proxy` network. Qlockify's nested-checkout, manual-build, environment, data-service, and operational patterns remain the baseline; its edge container and certificate lifecycle do not.
- Target: `/home/ubuntu/zarinpal-analytics-deployment`
- Implementation authorization: Granted by user on 2026-08-20
- Last updated: 2026-08-20
## Purpose
Use Qlockify's proven single-server deployment shape and environment-file layout for ZarinPal Analytics without requiring production IPs, credentials, or operator names during planning. Real values are supplied on the server when deployment occurs; they are not planning blockers and never enter public Git.
## Repository layout
```text
/home/ubuntu/zarinpal-analytics-deployment/
├── .env ignored deployment/TLS/backup values
├── backend/ ignored backend Git checkout
│ └── .env ignored backend/runtime values
├── frontend/ ignored frontend Git checkout
│ └── .env ignored public build values only
├── data/incoming/ ignored one-time CSV input
├── data/reports/ ignored import reports
├── releases/ ignored non-secret release records
└── backups/ ignored encrypted local backups
```
This deliberately simplifies Qlockify's extra application subdirectory layer. Each service checkout is directly under the deployment root and keeps an independent `.git` directory.
## File adaptation matrix
| Qlockify source | Planned ZarinPal artifact | Adaptation |
|---|---|---|
| `.env.sample` | `.env.example` | Keep TLS and ParsPack variable names; use the ZarinPal domain/prefix; remove alternate-domain and duplicate database values. |
| `backend/.env.sample` | backend-owned `.env.example`, consumed as `backend/.env` | Replace Django, JWT, Celery, OAuth, SMS, and demo-cleanup variables with the Go API/worker/import contracts. |
| `frontend/.env.sample` | frontend-owned `.env.example`, consumed as `frontend/.env` | Keep only public same-origin build configuration; never place a secret here. |
| `docker-compose.yml` | `compose.yaml` | Keep single-host orchestration and nested build contexts; replace Django/Celery/media services with API, worker, migrate, importer, and admin boundaries. |
| `backend/Dockerfile` | deployment backend image definition | Build the backend checkout once and select API/worker/migrate/import/admin commands from the same recorded backend revision. |
| `frontend/Dockerfile` | deployment frontend image definition | Build the ordinary frontend checkout, including its tracked font and license. |
| `nginx/*` | `nginx/*` | Keep HTTP-01, automatic renewal, temporary bootstrap certificate, and reload flow; use only `zarinpal.amiirkhl.ir` and ZarinPal routes. |
| `scripts/deploy.sh` | `scripts/deploy.sh` plus release-record helper | Keep manual Compose build/start structure; Git pulls happen interactively before the script; remove fetch/reset and partial-repository release selection. |
| `scripts/backup.sh` | `scripts/backup.sh` | Keep plain-SQL `pg_dump` and temporary archive structure; include PostgreSQL plus non-secret manifest/checksums only. |
| `scripts/backup-upload-s3.sh` | same filename | Keep root `.env` loading, OpenSSL/rclone flow, ParsPack support, and 3/7 retention; retain encrypted local artifacts. |
| `scripts/restore.sh` | same filename | Keep plain-SQL restore basis; remove env/media restore and add empty-target plus production guards. |
| `scripts/restore-from-s3.sh` | same filename | Keep rclone/decrypt handoff; require an explicit object id for production and validate it before restore. |
| `.gitea/workflows/*` | none | Do not port. ZarinPal deployment is manual and has no CI/CD pipeline or runner. |
## Environment layout
Future tracked examples contain placeholders or safe public defaults only. Production files are Git-ignored, owned by `ubuntu`, mode `0600`, excluded from backup, and loaded without printing resolved values.
### Deployment root `.env`
The future `.env.example` uses this deployment-owned contract:
```dotenv
COMPOSE_PROJECT_NAME=zarinpal_analytics
SSL_CERT_MODE=letsencrypt
LETSENCRYPT_EMAIL=
PRIMARY_DOMAIN=zarinpal.amiirkhl.ir
CERTBOT_STAGING=0
S3_BACKUP_BUCKET=
S3_BACKUP_PREFIX=backup/zarinpal-analytics
S3_BACKUP_ENDPOINT_URL=
S3_BACKUP_ACCESS_KEY_ID=
S3_BACKUP_SECRET_ACCESS_KEY=
BACKUP_ENCRYPTION_PASSPHRASE=
BACKUP_LOCAL_KEEP_LATEST=3
BACKUP_REMOTE_KEEP_LATEST=7
```
There is no `ALT_DOMAINS`, runner, registry, deploy-host, SSH-key, or CI variable. The endpoint, bucket, email, access keys, and passphrase may remain blank in the tracked example; the future validation command requires them only for the operation that consumes them.
### Backend `backend/.env`
The backend repository owns the complete Go application example. Deployment requires these semantic groups and consumes their eventual exact backend-owned names without maintaining a second competing template:
- production mode and structured-log level
- API listen address and public origin `https://zarinpal.amiirkhl.ir`
- `POSTGRES_HOST=postgres`, port `5432`, database/user/password
- `REDIS_HOST=redis`, port `6379`, password, and database selection required by the backend/Asynq contract
- session/cookie secrets and secure same-origin cookie settings
- analytics TTL, rate limits, worker concurrency `16`, per-merchant concurrency `4`, 60-second attempt timeout, and retry policy
- import source/report paths, schema version, readiness timeout, and bounded batch/resource settings
The same protected file supplies PostgreSQL, Redis, API, worker, migrate, importer, and admin services only with the variables each service needs. Qlockify-only Django, JWT, Celery, Google OAuth, SMS, demo-environment, media, and scheduled-cleanup variables are not carried over.
### Frontend `frontend/.env`
The frontend repository owns its exact public variable name. Its production value is the same-origin API prefix, initially `/api/v1`. No database, Redis, session, backup, AvalAI, or other secret is allowed in this file or the browser bundle.
## Manual deployment flow
1. The operator manually runs `git pull --ff-only origin main` in deployment, backend, and frontend checkouts and enters source-forge credentials interactively.
2. The future deploy command acquires the operation lock and verifies exact paths, remotes, `main`, equality with `origin/main`, and clean trees. It performs no Git fetch, pull, checkout, or reset.
3. It records all three commit hashes and validates the root/backend/frontend environment contracts without printing values.
4. It validates Compose, builds from the three recorded revisions, starts data services, runs the one-off migration, and replaces application services only after the required gates pass.
5. It validates private readiness plus public TLS/routing/font/SSE behavior, observes for 15 minutes, then marks the release active.
6. Rollback selects the prior release's three hashes, rebuilds, and re-runs the same checks. It never automatically reverses a migration.
The ordinary deployment path never invokes CSV import or credential generation. The first server uses the separately planned `first-start.sh` path after migration and before the first public activation.
## Always / Ask / Never
### Always
- Adapt from the named tracked Qlockify source file and record intentional differences.
- Keep all three real `.env` files ignored, mode `0600`, and out of backups/logs.
- Validate only the variables needed by the requested operation.
- Preserve exact-path guards before any mutation, cleanup, restore, or retention action.
### Ask first
- Rename a deployment-owned variable after implementation begins.
- Add a public origin/domain, external provider, persistent data class, or secret consumer.
- Change the manual three-repository release rule or Qlockify-derived backup encryption/retention contract.
### Never
- Port Qlockify's Gitea workflows, runner/deploy-key variables, or automated deployment.
- Port Qlockify's destructive `git reset --hard` synchronization.
- Copy Qlockify credentials, domains, database names, service names, or secret values.
- Back up or restore `.env` files, Redis, TLS keys, raw CSV, credentials, logs, or frontend assets.
- Create a second font path or private asset-injection workflow.
## Planning acceptance
- Every future deployment artifact maps to a named Qlockify source or a documented ZarinPal-only requirement.
- Root/backend/frontend environment ownership is unambiguous and has no duplicate secret source.
- Tracked examples can be committed with blank secret placeholders and useful safe defaults.
- Planning and local validation do not require a server IP, real DNS access, real ParsPack values, or named operators.
- Real values are required only at the first-server or operation-specific execution gate.

View File

@@ -51,3 +51,24 @@ Applied in the frontend repository's `DESIGN.md`: Persian RTL layout, right-side
Framework decisions remain subordinate to capability requirements. SvelteKit is used as a TypeScript application framework without assuming SEO/SSR; Go is used for the backend with architecture, DI, database access, and API choices approved through the specification process. Framework decisions remain subordinate to capability requirements. SvelteKit is used as a TypeScript application framework without assuming SEO/SSR; Go is used for the backend with architecture, DI, database access, and API choices approved through the specification process.
## Platform operations documentation
- Docker Compose application model: <https://docs.docker.com/compose/intro/compose-application-model/>
- Docker Compose startup order: <https://docs.docker.com/compose/how-tos/startup-order/>
- Docker Engine logging: <https://docs.docker.com/engine/logging/configure/>
- Nginx proxy module: <https://nginx.org/en/docs/http/ngx_http_proxy_module.html>
- Nginx TLS module: <https://nginx.org/en/docs/http/ngx_http_ssl_module.html>
- PostgreSQL backup and restore: <https://www.postgresql.org/docs/current/backup.html>
- PostgreSQL SQL dumps: <https://www.postgresql.org/docs/current/backup-dump.html>
- PostgreSQL official image tags: <https://hub.docker.com/_/postgres/tags>
- Redis official image tags: <https://hub.docker.com/_/redis/tags>
- Nginx official image tags: <https://hub.docker.com/_/nginx/tags>
- Certbot official image tags: <https://hub.docker.com/r/certbot/certbot/tags>
Applied in `PLATFORM-OPERATIONS-SPEC.md` through health-gated Compose startup, isolated networks and secrets, bounded container logs, TLS/SSE proxy behavior, logical PostgreSQL backup with guarded restore, and the approved exact image tags and resource limits.
## Qlockify deployment reference
Local read-only source: `D:/Programing/Scripts/Qlockify/qlockify-deployment`
Applied as the approved three-repository, manual server-build, Compose, Nginx, ACME, nested-checkout, and encrypted off-host backup reference. ZarinPal keeps that simple deployment shape while excluding environment/secrets from database backup packages, separating migrations/import from application startup, refusing destructive Git synchronization on dirty checkouts, guarding production restore, and omitting scheduled application workers.

View File

@@ -15,6 +15,5 @@ Use three Git repositories: deployment, Go backend, and SvelteKit frontend. The
- Service changes can run focused CI and then trigger the deployment workflow. - Service changes can run focused CI and then trigger the deployment workflow.
- Deployment remains the authoritative location for Compose, Nginx, backup/restore, and server automation. - Deployment remains the authoritative location for Compose, Nginx, backup/restore, and server automation.
- A release manifest must record the exact commit SHA of each service; local nested checkouts alone are not sufficient provenance. - A server-side release record must capture the exact deployment, backend, and frontend commit SHAs before each build; local nested checkouts alone are not sufficient provenance.
- Cross-repository contract changes require coordinated, backward-compatible rollout or an explicit release plan. - Cross-repository contract changes require coordinated, backward-compatible rollout or an explicit release plan.

View File

@@ -0,0 +1,62 @@
# ADR 0002: Manual server-build releases
- Status: Accepted
- Date: 2026-08-20
## Context
The project has three repositories but one small hackathon server. The Qlockify reference already proves a simple operational model: the deployment repository contains ignored backend/frontend checkouts, a trusted operator pulls source on the server, and Docker Compose builds and starts the stack there.
An earlier proposed design used CI/CD, a private image registry, immutable image digests, and cross-repository candidate events. The stakeholder explicitly rejected that complexity for this project. No CI/CD pipeline is needed.
The platform still needs to answer which source is running and how to return to the previous version.
## Decision
Use manual Qlockify-style deployment on the production server.
For a normal release, the trusted `ubuntu` user:
1. verifies the exact deployment/backend/frontend repository paths, remotes, branches, and clean working trees;
2. manually runs `git pull --ff-only origin main` in each checkout, entering the source-forge username/password interactively when prompted;
3. invokes the deployment command, which verifies that all three checkouts are clean, on `main`, and equal to `origin/main` without performing any Git pull;
4. records the exact three resulting Git commit hashes under the ignored `/home/ubuntu/zarinpal-analytics-deployment/releases/` directory;
5. builds application images locally with Docker Compose;
6. runs the dedicated migration and health/smoke checks; and
7. records the release as active only after validation passes.
Rollback selects the previous release record, checks out its three hashes, rebuilds locally, and repeats the checks. Database down-migration is never automatic; migrations follow expand/contract compatibility.
The deploy flow may adapt Qlockify's `deploy.sh`, but Git pulling is a separate operator prerequisite and the deployment command must not store source-forge credentials. It must not unconditionally `git reset --hard`. A dirty or divergent checkout stops for operator resolution so user changes are not destroyed.
There is no CI/CD workflow, Actions runner, automated cross-repository event, private image registry, or direct automated production mutation.
## Alternatives considered
### CI/CD plus private registry
- Pros: build-once artifacts, automated checks, stronger image provenance.
- Cons: more services, credentials, runner maintenance, workflow coordination, and implementation effort than the hackathon needs.
- Rejected: stakeholder chose the simpler manual Qlockify model.
### Per-service manual deployment
- Pros: each repository can be changed independently.
- Cons: services could be built from incompatible revisions and migration/config changes could race.
- Rejected: the deployment repository remains the single manual operational entry point.
### Git submodules
- Pros: deployment commits can pin service revisions.
- Cons: conflicts with accepted ADR 0001 and the Qlockify-style ignored checkout layout.
- Rejected: server release records provide the required three-hash provenance.
## Consequences
- The trusted deployment user and production server contain the source/build toolchain.
- Deployment is slower and less artifact-reproducible than build-once registry delivery, but exact Git hashes and build records preserve practical traceability.
- Source forge availability is required for new deployment and usually for rollback unless the target commits remain locally available.
- Manual quality commands and health checks are essential because no pipeline enforces them.
- Dirty/diverged checkout handling must fail safely rather than destroying changes.
- The server stores active and previous non-secret release records.
- Any future CI/CD or registry introduction requires a new approved ADR.

View File

@@ -0,0 +1,47 @@
# ADR 0003: Consume licensed font from the frontend repository
- Status: Accepted
- Date: 2026-08-20
## Context
The frontend requires the licensed Iran Yekan X Professional Persian-number variable webfont. The approved ownership decision is implemented by frontend commit `64f5ac2`, which tracks these required files in its ordinary source tree:
- `static/fonts/IRANYekanXVFaNumVF.woff2`
- `static/fonts/FontLicense.txt`
The deployment repository must not duplicate application assets or introduce a second delivery path.
## Decision
Treat both files as ordinary frontend-owned build inputs. Frontend CI builds them through the normal frontend context and the immutable frontend image contains the emitted font resource. Deployment consumes only that normal frontend image/build context.
Platform Operations does not create a separate asset-injection path, copy either file into deployment Git, or maintain font-specific credentials. Release provenance is the recorded frontend Git commit and the local image built from that checkout.
Frontend CI and deployment smoke checks must fail if the required source inputs are absent, the built resource is absent, or the resource is not served with the expected font MIME type. The deployment repository records no second binary or license copy.
## Alternatives considered
### Separate CI asset injection
- Rejected: it conflicts with the approved direct-tracking decision and creates a second source of build inputs.
### Copy assets into the deployment repository
- Pros: centralizes production build inputs.
- Cons: duplicates proprietary files, violates repository ownership, and can diverge from frontend source.
- Rejected: frontend owns static application assets.
### Runtime host mount
- Pros: keeps the binary outside the image.
- Cons: runtime drift, missing-asset failure after a deploy, harder rollback/provenance, and special server bootstrap.
- Rejected: the normal immutable frontend artifact must be self-contained.
## Consequences
- Frontend commit `64f5ac2` is the first approved provenance point for the tracked assets.
- Frontend repository policy and license compliance govern changes to the files.
- Deployment rollback restores the recorded frontend commit and rebuilds its font output through the normal context.
- No separate font injection workflow, font-specific deployment secret, or deployment-repository font copy is allowed.
- Platform smoke tests verify the emitted resource without reading or reproducing the license text.

View File

@@ -0,0 +1,11 @@
# ADR 0004: مرز AI در مربی رشد
## تصمیم
AvalAI فقط توضیح فارسی، اولویت‌بندی و انتخاب یک metric و سطح اقدام از allowlist را انجام می‌دهد. محاسبه baseline، target، sample threshold، deadline، پیشرفت، تکمیل و تغییر سطح دشواری در موتور قطعی Go انجام می‌شود. مدل اجازه پذیرش، رد، تکمیل یا اجرای اقدام را ندارد.
حافظه هر درخواست از نسخه جاری پروفایل پایانه، برنامه‌های ذخیره‌شده، ردها و نتایج ساخته می‌شود. provider memory خاموش و `store:false` است. پروفایل داده غیرقابل‌اعتماد محسوب می‌شود و دستورهای احتمالی داخل آن اجرا نمی‌شوند. داده تراکنش خام، payer key، secret و شناسه‌های داخلی وارد prompt یا log نمی‌شوند.
## پیامد عملیاتی
`AI_COACH_ENABLED` و `AI_PAGE_INSIGHTS_ENABLED` مستقل‌اند. production فقط با model، key و بودجه مثبت فعال می‌شود. outage برنامه معتبر قبلی و metricهای قطعی را حفظ می‌کند و failure را به‌عنوان پاسخ تازه AI نمایش نمی‌دهد.

View File

@@ -0,0 +1,7 @@
# ADR 0005: قرارداد ورود داده و watermark
## تصمیم
مربی رشد به منبع خاصی از پرداخت وابسته نیست. importer فعلی پس از commit موفق، checksum نسخه داده را از طریق `PaymentUpdateListener` اعلام می‌کند. adapter آینده webhook یا API همین قرارداد را پیاده می‌کند و به منطق coach دست نمی‌زند.
هر ارزیابی metric با watermark یکتا در PostgreSQL ثبت می‌شود. اجرای تکراری idempotent است. وقتی داده ثابت باشد، محصول «آخرین داده موجود» را نمایش می‌دهد و پیشرفت یا completion شبیه‌سازی نمی‌کند.

View File

@@ -0,0 +1,21 @@
# ADR 0006: Use the existing shared Caddy ingress
- Status: Accepted
- Date: 2026-08-22
## Context
The production host already runs a dedicated Caddy Compose project that owns ports 80/443, TLS certificates, and subdomain routing for Gitea, Penpot, Uptime Kuma, and other services. Running the previously planned Nginx/Certbot edge in this project would conflict on host ports and duplicate certificate ownership.
## Decision
ZarinPal Analytics connects its frontend and API containers to the existing external `caddy_proxy` Docker network using the stable aliases `zarinpal-frontend` and `zarinpal-backend`. Caddy routes `zarinpal.amiirkhl.ir` to those aliases. This Compose project publishes no host ports and owns no TLS material.
PostgreSQL, Redis, the worker, migration, importer, and admin services remain on an internal data network. The worker additionally joins a dedicated bridge egress network so approved external AI-provider calls and DNS resolution can succeed without attaching it to the shared proxy network. Only the API additionally joins `caddy_proxy`; the frontend joins only `caddy_proxy`. Caddy preserves the complete `/api` path and disables response buffering for proxied API traffic so authenticated SSE streams are delivered promptly.
## Consequences
- The Caddy deployment must exist first and create the external `caddy_proxy` network.
- Caddy configuration and this deployment must be released together for first activation.
- Nginx/Certbot-specific requirements in the earlier platform documents are superseded by this ADR. Their routing, TLS, and port-ownership intent is retained and implemented by the shared Caddy service.
- The shared proxy network is a trusted infrastructure boundary; no database, Redis, worker, or administrative service is attached to it. Worker internet access is isolated through the dedicated `egress` network.

View File

@@ -0,0 +1,156 @@
# PostgreSQL backup and restore runbook
- Status: Approved procedure specification; scripts not yet implemented
- RPO: 24 hours
- RTO: two hours
- Last updated: 2026-08-20
This runbook adapts Qlockify's logical dump, encrypted S3-compatible upload, local/remote retention, and restore flow while removing environment files from the backup and adding exact-destination/production confirmation guards.
## Scope and exclusions
Back up authoritative PostgreSQL data and the minimum manifest required to validate it. Do not include:
- Redis/AOF/cache/queue data
- `.env` or secret files
- deployment SSH and object-store credentials
- backup decryption key
- TLS private keys or ACME account data
- raw challenge CSV archives
- demo plaintext credential exports
- frontend font/license files
- Prometheus/Grafana runtime volumes or container logs
Redis loss is recovered through empty Redis plus idempotent analytics reconciliation/recomputation, not restore.
## Backup package contract
Each encrypted artifact corresponds to one manifest with:
```text
format_version
backup_id
created_at_utc
completed_at_utc
source_environment
source_host_id
postgres_server_version
dump_client_version
database_name
database_archive_sha256
database_archive_bytes
schema_migration_version
active_release_id
deployment_sha
backend_sha
frontend_sha
encryption_scheme_version
```
The manifest stores no password, connection URL, secret value, license identifier/text, raw font copy, CSV path/content, or merchant data sample. A checksum of the encrypted remote object is recorded separately from the plaintext archive checksum.
## Scheduled backup procedure
Run daily at `02:00 UTC` from the `ubuntu` user's reviewed host cron entry.
1. Acquire a backup lock that conflicts with restore and destructive migration but need not block ordinary application reads/writes.
2. Resolve and verify the exact production host/database identity. Validate free disk for temporary output plus the encrypted artifact.
3. Confirm PostgreSQL health, client/server major compatibility, active three-hash release id, and migration version.
4. Create a consistent PostgreSQL 18 plain-SQL dump using Qlockify's reviewed `pg_dump --format=plain --clean --if-exists --no-owner --no-privileges` pattern. Capture stderr and fail on a non-zero exit.
5. Reject an empty output, verify the expected PostgreSQL dump header/format, and calculate its SHA-256.
6. Build the non-secret manifest and calculate the plaintext archive SHA-256.
7. Encrypt the package before durable local retention with Qlockify's `openssl enc -aes-256-cbc -salt -pbkdf2 -iter 200000` flow. The manifest records the scheme/version and SHA-256 integrity evidence because CBC is not authenticated encryption.
8. Upload over TLS to the private S3-compatible destination. Upload to a temporary object key, verify remote size/checksum/metadata, then finalize the immutable timestamped key.
9. Mark the backup successful only after remote verification. Record success timestamp, duration, and size in structured MVP evidence; export the same signals as metrics in the required post-MVP observability phase.
10. Retain the newest three verified encrypted local artifacts and seven verified encrypted remote artifacts. Delete stale artifacts only after the new remote object is verified; never delete the last known-good local or remote backup.
11. Release the lock and ensure temporary plaintext files are removed from the exact temporary directory.
A cleanup failure is a ticket, not a reason to mark the backup failed if the verified new copy exists. An upload/verification failure is a backup failure and preserves all prior copies.
## Daily verification
- last verified remote backup age is under 30 hours
- archive/manifest/encrypted-object checksums and sizes are non-zero and agree
- retention counts are at least one and no greater than approved limits after cleanup
- backup duration/size are within expected bounds or investigated
- no excluded filenames/classes appear in the package-content inspection
- local backup directory and remote bucket are not publicly readable
- decryption key is retrievable from separate escrow by authorized operators without exposing it in the check record
## Clean-destination restore rehearsal
Run monthly and before any approved destructive migration.
1. Select an explicit backup id, not an unexamined implicit `latest`, and record why it was chosen.
2. Provision an empty non-production destination with compatible PostgreSQL and enough free space. Confirm its environment/hostname/database do not match production.
3. Download the exact encrypted object to a new restrictive temporary directory.
4. Verify remote-object checksum/size, decrypt, validate the package and plain-SQL checksum/header, and parse the manifest.
5. Treat the dump as trusted code only if it came from the approved bucket/path and passes provenance checks. PostgreSQL warns that restore executes statements controlled by the dump source.
6. Create a new empty database from `template0` or the equivalent approved empty-cluster procedure. Do not reuse a partially populated database.
7. Restore with `psql -X --set ON_ERROR_STOP=on` and the reviewed transaction/owner/privilege behavior. Preserve complete stdout/stderr in a restricted evidence path without secrets.
8. Check out/build the selected release record if needed, then run its validation suite:
- expected migration/import manifests
- representative table/session/attempt counts and invariants
- at least two merchant logins and cross-tenant denial
- payer-card keys absent from API/log output
- Redis initially empty, analytics recomputation succeeds, four sections/SSE work
- frontend and API compatibility for the manifest's release
9. Record elapsed time, achieved RPO, achieved RTO, sizes/counts, validation result, operator, and remediation.
10. Destroy rehearsal resources only after evidence is retained and the exact destination is revalidated.
## Production restore approval guard
Production restore is destructive/exclusive and always requires explicit human approval. The future implementation must require all of these independent inputs:
- `RESTORE_ENVIRONMENT=production`
- exact expected host identity matching the current provider instance
- exact expected database name
- explicit backup id/object key (no automatic `latest`)
- a typed confirmation phrase containing both production and the backup id
- active maintenance mode and deployment/restore lock
- approved incident/change record and named operator
- current-state backup when PostgreSQL is readable and doing so will not worsen the incident
If any input is absent or mismatched, exit before stopping writers, dropping/renaming a database, extracting over a target, or running `psql`.
## Production restore sequence
1. Announce maintenance and stop public writes/new sessions. Preserve an operator-only health path.
2. Acquire the exclusive deployment/restore lock; stop worker before backend so no jobs write during restore.
3. Capture the active/previous three-hash release records, database identity, incident timestamps, and current-state backup when safe.
4. Perform all download/decrypt/checksum/archive/provenance checks from the rehearsal procedure before mutating database state.
5. Restore into a newly created empty database/cluster. Do not pipe an unchecked dump directly into the active production database and do not recursively delete a computed volume/path.
6. Validate restore completeness and application invariants using the three recorded repository commits compatible with the backup manifest. Apply only explicitly approved forward migrations.
7. Start with empty Redis. Start backend/worker, confirm readiness, idempotent stale-run reconciliation, cold-cache analytics, sessions, and SSE behavior.
8. Switch application connectivity to the validated restored destination using the approved implementation strategy.
9. Run public TLS/login/dashboard plus private database/queue/tenant smoke checks. Observe for 15 minutes.
10. Reopen traffic, mark the restore complete, and schedule a fresh backup. Retain the old database until the incident owner approves disposal under a separate exact-target procedure.
Do not automatically restore `.env`/secrets, TLS, Redis, or frontend assets from a database backup. Reconstruct platform configuration from Git/release records and secrets from separate escrow.
## Failure handling
| Failure | Action |
|---|---|
| Backup lock already held | Exit without starting a second backup; alert if stale |
| `pg_dump` non-zero, empty, or invalid plain-SQL output | Delete only the exact failed temporary artifact; keep prior backups; page on age threshold |
| Encryption failure | Do not persist/upload plaintext; secure exact temp path and investigate |
| Remote upload/checksum failure | Keep encrypted local artifact and all prior remote copies; bounded retry; alert |
| Retention cleanup failure | Keep extra copies; ticket; never broaden deletion globs/paths |
| Manifest/archive mismatch | Quarantine artifact and select another known-good backup |
| Restore SQL/object failure | Keep traffic closed, preserve logs/destination, do not rerun blindly into partial DB |
| Post-restore invariant failure | Do not switch/reopen; return to old database if safe or select another backup |
| Missing decryption key | Stop; encrypted backup is unrecoverable without the separately escrowed key |
| Wrong environment/host/database confirmation | Abort before mutation and record denied attempt |
## Acceptance evidence
- [ ] Daily backup completed and verified remotely within 30 hours
- [ ] Backup contains PostgreSQL archive plus non-secret manifest only
- [ ] Three local/seven remote verified encrypted artifacts retained
- [ ] Restore rehearsal uses an explicit backup id and clean non-production database
- [ ] Archive trust/checksum/listing/compatibility checks precede restore
- [ ] Application, tenant, session, cold-cache, worker, and SSE validations pass
- [ ] Achieved RPO <=24 hours and RTO <=2 hours recorded
- [ ] Every missing production confirmation factor aborts before mutation
- [ ] No Redis, env, secrets, TLS keys, CSV, credentials, font/license, logs, or monitoring runtime data enter backup

View File

@@ -0,0 +1,158 @@
# First-server bootstrap runbook
- Status: Approved procedure specification; commands/artifacts not yet implemented
- Target: `zarinpal.amiirkhl.ir`
- Owner: Platform operator
- Last updated: 2026-08-20
This is the safe order for preparing a new production host. It is intentionally a human-reviewed checklist, not a copy-paste bootstrap script. Stop on any failed validation and preserve the existing server/DNS state until the failure is understood.
## Preconditions and recorded inputs
Before connecting to the host, record in the change ticket/release record:
- server provider, instance id, public IPv4/IPv6, region, CPU, RAM, disk type/size, and console/recovery access
- Ubuntu 24.04 LTS image/source for the new 2-core/2-GB server, with Docker already installed and the `ubuntu` user available
- operator names, maintenance window, rollback/abandonment condition, and emergency contact
- DNS zone owner and current `A`/`AAAA`/CAA records for `zarinpal.amiirkhl.ir`
- SSH source allowlist and at least two tested operator public keys
- confirmation that the trusted `ubuntu` user has the required Docker-group access
- approved deployment, backend, and frontend repository URLs and expected nested paths
- object-storage bucket/endpoint, retention capability, backup credentials, and separately escrowed encryption key
- target `main` branches and an empty location for the first three-hash release record
- secret inventory and owner; never put values in the ticket, shell history, or this repository
Do not change DNS before the host passes local and direct-IP validation. Do not reuse Qlockify credentials, certificates, volumes, backup paths, or Git credentials.
## Phase 1: establish recoverable access
1. Open the provider console/recovery channel and confirm it works independently of SSH.
2. Log in using the provider's initial method. Create named operator accounts and install only approved public keys.
3. Confirm a second operator can log in in a new session before changing SSH configuration.
4. Disable direct root SSH and password authentication only after key access and recovery console are proven.
5. Restrict SSH to the operator source allowlist at the provider firewall and host firewall. Keep the current session open while testing a new one.
6. Configure time synchronization and verify UTC plus the documented `Asia/Tehran` application assumption. The host itself should log in UTC.
7. Apply all supported security updates and reboot if required. Re-test console and SSH access after reboot.
Evidence: OS release/kernel, update result, time-sync state, two successful key logins, failed password/root-login test, and firewall rules. Do not record public key private material or session output containing secrets.
## Phase 2: harden and size the host
1. Set a unique hostname and record the provider instance id alongside it.
2. Configure default-deny inbound firewall rules. Initially allow SSH from the operator allowlist and TCP 80/443; verify from both an allowed and disallowed external source.
3. Enable unattended security updates with a documented reboot policy and alert path.
4. Configure disk/inode monitoring, time sync, audit/system logs, log rotation, and OOM visibility before application deployment.
5. Create the approved Qlockify-style runtime paths with explicit `ubuntu` ownership:
```text
/home/ubuntu/zarinpal-analytics-deployment/ deployment repository and root .env
/home/ubuntu/zarinpal-analytics-deployment/backend/ ignored backend checkout and backend .env
/home/ubuntu/zarinpal-analytics-deployment/frontend/ ignored frontend checkout and frontend .env
/home/ubuntu/zarinpal-analytics-deployment/releases/ ignored non-secret release records
/home/ubuntu/zarinpal-analytics-deployment/backups/ ignored encrypted local backup retention
```
6. Verify every resolved absolute path is on the intended filesystem, not a symlink to a broad/unexpected target, before applying ownership or any future recursive operation.
7. Reserve enough free space for PostgreSQL growth, one temporary logical dump, three encrypted local backups, container images, and logs while retaining the platform alert floor of at least 20% and 10 GiB free.
8. Create and enable the approved 2 GB swap file, use restrictive permissions, persist it in `/etc/fstab`, and verify it after reboot.
## Phase 3: install and constrain Docker
1. Verify the preinstalled Docker Engine and Compose plugin came from Docker's approved Ubuntu repository; update/install only if verification fails, then record installed versions.
2. Confirm only the trusted `ubuntu` user has the required `docker` group access. Record that this access is effectively root-equivalent and restrict the account accordingly.
3. Configure the daemon's bounded logging policy before starting application containers.
4. Ensure Docker starts after reboot and that no unauthenticated TCP daemon socket is enabled.
5. Pull a harmless pinned public test image, verify its tag/digest, run it without published ports, then remove the test container.
6. Confirm the `ubuntu` user can run Docker Compose without using a broader shared administrator account.
Evidence: Engine/Compose versions, daemon security configuration, socket/group ownership, deploy-user membership, log driver/options, and test-image result.
## Phase 4: install the deployment control plane
1. From the `ubuntu` account, clone the deployment repository into `/home/ubuntu/zarinpal-analytics-deployment`, then clone backend and frontend into its exact ignored nested paths. Enter the source-forge HTTPS username/password interactively; do not configure a credential helper, token, or deploy key.
2. Confirm no Git credential was persisted in repository configuration, shell scripts, environment files, or the operating-system credential store.
3. Validate ownership, remote URL, `main` branch, current SHA, clean status, and independent `.git` directory for every repository.
4. Create the Qlockify-style Git-ignored environment files: deployment-root `.env` plus nested backend/frontend `.env`, all owned by `ubuntu`, mode 0600, and excluded from backup. Verify consumers receive only required values.
5. Manually run `git pull --ff-only origin main` in each repository, entering HTTPS credentials interactively, and confirm each checkout equals `origin/main`; do not reset, build, or start yet.
6. Record the three initial commit hashes and selected infrastructure image tags in a non-secret draft release record.
7. Render/validate Compose configuration without printing secret values. Confirm only Nginx publishes 80/443.
Do not run `docker compose up`, migrations, import, credential generation, restore, or DNS cutover during this phase.
## Phase 5: DNS and TLS staging
1. Lower DNS TTL in advance if an existing record is moving. Preserve the previous values and rollback time.
2. Point `A` (and `AAAA` only if IPv6 is actually routed/firewalled) for `zarinpal.amiirkhl.ir` after direct-IP Nginx/host validation. No `www` or alternate hostname is configured.
3. Verify public authoritative DNS from multiple resolvers and confirm the address matches the current server.
4. Start only the minimum edge services required for ACME HTTP-01. Verify the challenge path from an external network.
5. Obtain a staging certificate first and exercise renewal. Then request the production certificate.
6. Validate certificate names, chain, expiry, TLS 1.2/1.3, canonical redirects, unknown-host rejection, and renewal permissions.
7. Enable HSTS only after successful production issuance and renewal evidence; preload remains a separate approval.
If issuance fails, stop repeated production requests, inspect DNS/CAA/firewall/challenge routing, and continue serving no plaintext application content.
## Phase 6: first database, dataset, and application release
1. Acquire the production deployment lock and record the operator/release id.
2. Place the approved `challenge_data.csv.gz` at the exact Git-ignored deployment `data/incoming/` path. Verify regular-file/read-only properties, size, and pinned SHA-256; do not copy it into an image.
3. Place the independently reviewed calendar JSON at the absolute `CALENDAR_FILE` path. Verify it is a regular read-only file, record its checksum and reviewer, and reject candidate data whose `sourceChecksum` still contains a review placeholder.
4. Invoke tracked `scripts/first-start.sh`. It starts PostgreSQL and Redis, waits with bounded backoff, applies the one-off migration, runs the one-shot importer and reconciliation, bootstraps the reviewed calendar through its audited `DRAFT`/`APPROVED`/`ACTIVE` states, and only then starts public services.
5. Review the JSON/Markdown import report, durable import-run/issues metadata, active calendar version, and calendar audit records. Fatal validation, resource, migration, import, reconciliation, calendar, or scheduling failure must exit nonzero before backend/frontend/Nginx activation.
6. Verify backend and worker liveness/readiness, worker heartbeat, Asynq `v0.26.0` compatibility, queue limits, and absence of unintended published ports.
7. Verify the frontend static sentinel plus the emitted Iran Yekan font resource. The source font/license remain frontend-owned; no deployment-side injection or copy occurs.
8. Verify Nginx public generic health, canonical redirect, security headers, login page, SSE behavior, and that only 80/443 are public.
9. Run demo credential generation as a separate explicit administrative job. Store one-time plaintext output outside Git/logs/backups.
10. Authenticate as at least two merchant accounts, verify different dashboards and cross-tenant isolation, then remove or escrow plaintext credential output.
11. Verify MVP operator checks: Compose health/restarts, structured logs, disk, certificate, queue/Redis state, and backup destination access.
12. Record the active three-hash release only after all postflight checks pass.
Failure before the active-release switch restores the prior/empty known state. Do not improvise a database down migration.
## Phase 7: bootstrap evidence review
1. Confirm the sole completed import run matches the pinned checksum, schema, source/session/attempt/merchant/terminal/category counts, and contains no fatal issue.
2. Confirm no raw staging remains and the ordinary `docker compose up -d` path cannot invoke migration/import/admin profiles.
3. Run `first-start.sh` a second time only in the controlled acceptance rehearsal and confirm the identical dataset is a safe no-op rather than another promotion.
4. Archive only safe non-sensitive release/import evidence; exclude the CSV, raw rows, reports containing restricted values, and credentials.
Never add the source archive or credentials to Git, a container image, backup package, or diagnostic bundle.
## Phase 8: backup and handoff gate
1. Run the first encrypted logical PostgreSQL backup.
2. Verify package contents, plain-SQL header/checksum, encrypted local artifact, remote upload, remote checksum, manifest, and retention counts.
3. Restore that backup to a clean non-production destination and complete application/tenant/analytics validation within the two-hour RTO.
4. Perform and record the MVP manual certificate, disk, backup-age, availability, API error, SSE heartbeat, and queue-age checks.
5. Record current release, DNS, certificate, backup id, achieved RPO/RTO, remaining exceptions, and on-call ownership.
6. Remove temporary bootstrap credentials/files and close provider rules that are no longer needed.
The host is not considered MVP-ready until restore rehearsal and every manual operator check passes. Prometheus/Grafana and automated alert delivery are the required next phase before AI work.
## Stop/rollback conditions
Stop without progressing when:
- recovery console or second-operator SSH access is not proven
- resolved paths, host identity, repository remotes/branches, environment, or release record do not match
- a repository is dirty/diverged unexpectedly or a build/source dependency cannot be verified
- unexpected ports are externally reachable
- DNS points somewhere unexpected or certificate names/chain are invalid
- migration, readiness, authenticated smoke, tenant isolation, worker/SSE, manual operational checks, backup, or restore rehearsal fails
- secrets appear in output, files have broad permissions, or the deployment checkout is dirty unexpectedly
Rollback DNS to the recorded prior values when the new host cannot pass the cutover window. Preserve logs and manifests, revoke any exposed credential, and do not delete the failed host until evidence and required backups are secured.
## Completion evidence
- [ ] Provider/host identity and capacity recorded
- [ ] Console plus two key-only operator paths proven
- [ ] Firewall external tests show only approved ports
- [ ] Docker/Compose versions and privilege model recorded
- [ ] Three repository remotes/branches/SHAs and release record verified
- [ ] Secrets outside Git with restrictive ownership/mode
- [ ] TLS issuance and renewal tested
- [ ] Migration, API, worker, frontend/font, Nginx, SSE, and tenant smoke pass
- [ ] One-time import reconciles and demo credentials stay outside logs/Git
- [ ] MVP manual health/log/disk/certificate/backup/queue checks pass
- [ ] Encrypted off-host backup and clean restore rehearsal pass within RTO
- [ ] Active/previous release ids and operator handoff recorded

224
scripts/LocalDev.psm1 Normal file
View File

@@ -0,0 +1,224 @@
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
function Read-DotEnvFile {
[CmdletBinding()]
param([Parameter(Mandatory)][string]$Path)
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
throw "Environment file not found: $Path"
}
$values = @{}
$lineNumber = 0
foreach ($rawLine in Get-Content -LiteralPath $Path) {
$lineNumber++
$line = $rawLine.Trim()
if (-not $line -or $line.StartsWith('#')) { continue }
if ($line -notmatch '^([A-Za-z_][A-Za-z0-9_]*)=(.*)$') {
throw "invalid dotenv entry at ${Path}:$lineNumber"
}
$name = $Matches[1]
$value = $Matches[2]
if ($values.ContainsKey($name)) {
throw "duplicate variable '$name' in $Path"
}
if ($value.Length -ge 2 -and (($value[0] -eq '"' -and $value[-1] -eq '"') -or ($value[0] -eq "'" -and $value[-1] -eq "'"))) {
$value = $value.Substring(1, $value.Length - 2)
}
$values[$name] = $value
}
return $values
}
function Resolve-LocalDevCommands {
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$BackendPath,
[Parameter(Mandatory)][string]$FrontendPath
)
foreach ($entrypoint in 'api', 'worker', 'scheduler') {
$path = Join-Path $BackendPath "cmd\$entrypoint"
if (-not (Test-Path -LiteralPath $path -PathType Container)) {
throw "Backend entrypoint cmd/$entrypoint is missing under $BackendPath"
}
}
$packageJsonPath = Join-Path $FrontendPath 'package.json'
if (-not (Test-Path -LiteralPath $packageJsonPath -PathType Leaf)) {
throw "Frontend package.json is missing under $FrontendPath"
}
$packageJson = Get-Content -Raw -LiteralPath $packageJsonPath | ConvertFrom-Json
if (-not $packageJson.scripts -or -not $packageJson.scripts.dev) {
throw "Frontend package.json does not define the required 'dev' script"
}
return @{
Api = @{ FilePath = 'go'; Arguments = @('run', './cmd/api'); WorkingDirectory = $BackendPath }
Worker = @{ FilePath = 'go'; Arguments = @('run', './cmd/worker'); WorkingDirectory = $BackendPath }
Scheduler = @{ FilePath = 'go'; Arguments = @('run', './cmd/scheduler'); WorkingDirectory = $BackendPath }
Frontend = @{ FilePath = 'npm.cmd'; Arguments = @('run', 'dev', '--', '--host', '127.0.0.1'); WorkingDirectory = $FrontendPath }
}
}
function Test-TcpEndpoint {
[CmdletBinding()]
param([Parameter(Mandatory)][string]$HostName, [Parameter(Mandatory)][int]$Port, [int]$TimeoutMilliseconds = 1000)
$client = [System.Net.Sockets.TcpClient]::new()
try {
$task = $client.ConnectAsync($HostName, $Port)
return $task.Wait($TimeoutMilliseconds) -and $client.Connected
}
catch { return $false }
finally { $client.Dispose() }
}
function Get-StatePath {
param([string]$DeploymentPath)
return Join-Path $DeploymentPath '.local\processes.json'
}
function Get-RecordedProcesses {
param([string]$DeploymentPath)
$statePath = Get-StatePath -DeploymentPath $DeploymentPath
if (-not (Test-Path -LiteralPath $statePath -PathType Leaf)) { return @() }
$items = @(Get-Content -Raw -LiteralPath $statePath | ConvertFrom-Json)
return $items
}
function Test-RecordedProcess {
param($Record)
try {
$recordedProcessId = [int]$Record.Pid
if ($recordedProcessId -le 0) { return $false }
$recordedStartTime = if ($Record.StartTimeUtc -is [datetime]) {
$Record.StartTimeUtc.ToUniversalTime()
}
else {
[datetime]::Parse(
[string]$Record.StartTimeUtc,
[System.Globalization.CultureInfo]::InvariantCulture,
[System.Globalization.DateTimeStyles]::RoundtripKind
).ToUniversalTime()
}
}
catch { return $false }
$process = Get-Process -Id $recordedProcessId -ErrorAction SilentlyContinue
if (-not $process) { return $false }
try {
return $process.StartTime.ToUniversalTime().Ticks -eq $recordedStartTime.Ticks
}
catch { return $false }
}
function Invoke-LocalDevCheck {
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$DeploymentPath,
[Parameter(Mandatory)][string]$BackendPath,
[Parameter(Mandatory)][string]$FrontendPath,
[string]$PostgresHost = '127.0.0.1',
[int]$PostgresPort = 5432,
[string]$RedisHost = '127.0.0.1',
[int]$RedisPort = 6379
)
$null = Resolve-LocalDevCommands -BackendPath $BackendPath -FrontendPath $FrontendPath
foreach ($tool in 'go', 'npm.cmd') {
if (-not (Get-Command $tool -ErrorAction SilentlyContinue)) { throw "Required command is not installed or on PATH: $tool" }
}
$backendEnv = Join-Path $BackendPath '.env'
if (-not (Test-Path -LiteralPath $backendEnv -PathType Leaf)) { throw "Backend .env is missing: $backendEnv" }
$null = Read-DotEnvFile -Path $backendEnv
if (-not (Test-TcpEndpoint -HostName $PostgresHost -Port $PostgresPort)) { throw "PostgreSQL is not reachable at ${PostgresHost}:$PostgresPort" }
if (-not (Test-TcpEndpoint -HostName $RedisHost -Port $RedisPort)) { throw "Redis is not reachable at ${RedisHost}:$RedisPort" }
[pscustomobject]@{ Go = 'ok'; Node = 'ok'; PostgreSQL = 'reachable'; Redis = 'reachable'; Contracts = 'ok' }
}
function Start-LocalProcess {
param([string]$Name, [hashtable]$Command, [hashtable]$Environment, [string]$LogDirectory)
$resolved = Get-Command $Command.FilePath -ErrorAction Stop
$previous = @{}
foreach ($entry in $Environment.GetEnumerator()) {
$previous[$entry.Key] = [Environment]::GetEnvironmentVariable($entry.Key, 'Process')
[Environment]::SetEnvironmentVariable($entry.Key, [string]$entry.Value, 'Process')
}
try {
$stdout = Join-Path $LogDirectory "$Name.out.log"
$stderr = Join-Path $LogDirectory "$Name.err.log"
$process = Start-Process -FilePath $resolved.Source -ArgumentList $Command.Arguments -WorkingDirectory $Command.WorkingDirectory -RedirectStandardOutput $stdout -RedirectStandardError $stderr -WindowStyle Hidden -PassThru
}
finally {
foreach ($entry in $previous.GetEnumerator()) {
[Environment]::SetEnvironmentVariable($entry.Key, $entry.Value, 'Process')
}
}
Start-Sleep -Milliseconds 750
if ($process.HasExited) { throw "$Name exited during startup; inspect $stderr" }
$process.Refresh()
return [pscustomobject]@{ Name = $Name; Pid = $process.Id; StartTimeUtc = $process.StartTime.ToUniversalTime().ToString('o') }
}
function Stop-RecordedProcess {
param($Record)
if (-not (Test-RecordedProcess $Record)) { return }
& taskkill.exe /PID $Record.Pid /T /F | Out-Null
}
function Start-LocalDev {
[CmdletBinding()]
param([string]$DeploymentPath, [string]$BackendPath, [string]$FrontendPath)
$existing = @(Get-RecordedProcesses -DeploymentPath $DeploymentPath | Where-Object { Test-RecordedProcess $_ })
if ($existing.Count -gt 0) { throw 'Local services are already recorded as running; use status or stop first' }
$null = Invoke-LocalDevCheck -DeploymentPath $DeploymentPath -BackendPath $BackendPath -FrontendPath $FrontendPath
$commands = Resolve-LocalDevCommands -BackendPath $BackendPath -FrontendPath $FrontendPath
$backendEnvironment = Read-DotEnvFile -Path (Join-Path $BackendPath '.env')
$runtimeDirectory = Join-Path $DeploymentPath '.local'
$logDirectory = Join-Path $runtimeDirectory 'logs'
New-Item -ItemType Directory -Path $logDirectory -Force | Out-Null
$started = @()
try {
$started += Start-LocalProcess -Name 'api' -Command $commands.Api -Environment $backendEnvironment -LogDirectory $logDirectory
$started += Start-LocalProcess -Name 'worker' -Command $commands.Worker -Environment $backendEnvironment -LogDirectory $logDirectory
$started += Start-LocalProcess -Name 'scheduler' -Command $commands.Scheduler -Environment $backendEnvironment -LogDirectory $logDirectory
$started += Start-LocalProcess -Name 'frontend' -Command $commands.Frontend -Environment @{} -LogDirectory $logDirectory
$started | ConvertTo-Json | Set-Content -LiteralPath (Get-StatePath -DeploymentPath $DeploymentPath) -Encoding utf8
return $started
}
catch {
foreach ($record in $started) {
Stop-RecordedProcess $record
}
throw
}
}
function Stop-LocalDev {
[CmdletBinding()]
param([string]$DeploymentPath)
foreach ($record in @(Get-RecordedProcesses -DeploymentPath $DeploymentPath)) {
Stop-RecordedProcess $record
}
Remove-Item -LiteralPath (Get-StatePath -DeploymentPath $DeploymentPath) -Force -ErrorAction SilentlyContinue
}
function Get-LocalDevStatus {
[CmdletBinding()]
param([string]$DeploymentPath)
return @(Get-RecordedProcesses -DeploymentPath $DeploymentPath | ForEach-Object {
[pscustomobject]@{ Name = $_.Name; Pid = $_.Pid; Running = [bool](Test-RecordedProcess $_) }
})
}
Export-ModuleMember -Function Read-DotEnvFile, Resolve-LocalDevCommands, Test-TcpEndpoint, Invoke-LocalDevCheck, Start-LocalDev, Stop-LocalDev, Get-LocalDevStatus

81
scripts/deploy.sh Normal file
View File

@@ -0,0 +1,81 @@
#!/usr/bin/env bash
set -Eeuo pipefail
DEPLOY_ROOT="${DEPLOY_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
BACKEND_ROOT="$DEPLOY_ROOT/backend"
FRONTEND_ROOT="$DEPLOY_ROOT/frontend"
COMPOSE=(docker compose --project-directory "$DEPLOY_ROOT" -f "$DEPLOY_ROOT/compose.yaml")
fail() {
printf '[deploy] ERROR: %s\n' "$*" >&2
exit 1
}
log() {
printf '[deploy] %s\n' "$*"
}
check_repo() {
local label="$1"
local path="$2"
[[ -d "$path/.git" ]] || fail "$label repository is missing at $path"
[[ "$(git -C "$path" branch --show-current)" == "main" ]] || fail "$label must be on main"
[[ -z "$(git -C "$path" status --porcelain)" ]] || fail "$label working tree is not clean"
git -C "$path" rev-parse --verify origin/main >/dev/null 2>&1 || fail "$label has no origin/main; pull it manually first"
[[ "$(git -C "$path" rev-parse HEAD)" == "$(git -C "$path" rev-parse origin/main)" ]] || fail "$label does not match origin/main; pull with --ff-only first"
}
wait_healthy() {
local service="$1"
local deadline=$((SECONDS + 180))
local container_id status
while (( SECONDS < deadline )); do
container_id="$("${COMPOSE[@]}" ps -q "$service")"
if [[ -n "$container_id" ]]; then
status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_id")"
[[ "$status" == "healthy" || "$status" == "running" ]] && return 0
fi
sleep 3
done
"${COMPOSE[@]}" ps
fail "$service did not become healthy"
}
cd "$DEPLOY_ROOT"
[[ -f .env && -f backend/.env && -f frontend/.env ]] || fail "create .env, backend/.env, and frontend/.env first"
check_repo deployment "$DEPLOY_ROOT"
check_repo backend "$BACKEND_ROOT"
check_repo frontend "$FRONTEND_ROOT"
docker network inspect "${CADDY_NETWORK:-caddy_proxy}" >/dev/null 2>&1 || fail "the external Caddy network does not exist"
IMAGE_TAG="$(git -C "$BACKEND_ROOT" rev-parse --short=12 HEAD)-$(git -C "$FRONTEND_ROOT" rev-parse --short=12 HEAD)"
export IMAGE_TAG
log "validating production configuration"
"${COMPOSE[@]}" config --quiet
log "building application images"
"${COMPOSE[@]}" build backend frontend
log "starting data services"
"${COMPOSE[@]}" up -d postgres redis
wait_healthy postgres
wait_healthy redis
log "applying database migrations"
"${COMPOSE[@]}" --profile tools run --rm migrate
log "starting API, worker, and frontend"
"${COMPOSE[@]}" up -d backend worker frontend
wait_healthy backend
wait_healthy worker
wait_healthy frontend
log "deployment is healthy"
printf '[deploy] deployment=%s backend=%s frontend=%s image_tag=%s\n' \
"$(git rev-parse HEAD)" \
"$(git -C "$BACKEND_ROOT" rev-parse HEAD)" \
"$(git -C "$FRONTEND_ROOT" rev-parse HEAD)" \
"$IMAGE_TAG"
"${COMPOSE[@]}" ps

26
scripts/local-dev.ps1 Normal file
View File

@@ -0,0 +1,26 @@
[CmdletBinding()]
param(
[Parameter(Position = 0)][ValidateSet('check', 'start', 'stop', 'status')][string]$Action = 'status',
[string]$BackendPath,
[string]$FrontendPath
)
$ErrorActionPreference = 'Stop'
$deploymentPath = Split-Path $PSScriptRoot -Parent
if (-not $BackendPath) { $BackendPath = Join-Path (Split-Path $deploymentPath -Parent) 'zarinpal-analytics-backend' }
if (-not $FrontendPath) { $FrontendPath = Join-Path (Split-Path $deploymentPath -Parent) 'zarinpal-analytics-frontend' }
Import-Module (Join-Path $PSScriptRoot 'LocalDev.psm1') -Force
try {
switch ($Action) {
'check' { Invoke-LocalDevCheck -DeploymentPath $deploymentPath -BackendPath $BackendPath -FrontendPath $FrontendPath | Format-List }
'start' { Start-LocalDev -DeploymentPath $deploymentPath -BackendPath $BackendPath -FrontendPath $FrontendPath | Format-Table -AutoSize }
'stop' { Stop-LocalDev -DeploymentPath $deploymentPath; Write-Output 'Local services stopped.' }
'status' { Get-LocalDevStatus -DeploymentPath $deploymentPath | Format-Table -AutoSize }
}
}
catch {
Write-Error $_
exit 1
}

146
tasks/plan.md Normal file
View File

@@ -0,0 +1,146 @@
# Implementation Plan: Platform Operations
- Status: Approved — Phase 4 implementation authorized 2026-08-20
- Capability: Platform operations
- Task list target: `tasks/todo.md`
- Planning baseline: `docs/QLOCKIFY-ADAPTATION-CONTRACT.md`
- Last updated: 2026-08-20
## Outcome
Adapt the read-only Qlockify deployment repository into a manual, production-shaped ZarinPal Analytics deployment for `zarinpal.amiirkhl.ir`. Planning does not wait for a server IP, live DNS, real credentials, or named operators. Tracked artifacts use safe defaults and blank secret placeholders; real values are supplied only when an operation needs them.
There is no CI/CD pipeline, runner, registry, automated Git synchronization, or private font injection. Prometheus/Grafana remain a required post-MVP, pre-AI phase.
The user authorized implementation of the tasks below on 2026-08-20.
## Dependency graph
```text
approved spec + Qlockify adaptation contract + explicit build authorization
|
+--> environment examples/validation --> data-service Compose
| | |
| +--> release record/lock +--> API/worker/one-offs
| |
+--> frontend build --------------------------------+
|
Nginx routing --> Certbot/TLS
|
first-start --> deploy --> rollback
| |
+-----------+--> backup/upload
|
guarded restore
|
first-server MVP gate
|
Prometheus --> Grafana
|
AI may begin
```
## Fixed architecture decisions
- Preserve ADR 0001's three independent repositories and direct ignored `backend/` and `frontend/` checkouts.
- Follow ADR 0002: the operator pulls all repositories interactively before deployment; scripts verify/record but do not pull or reset Git.
- Follow ADR 0003/frontend commit `64f5ac2`: consume the tracked font/license through the normal frontend build context.
- Pin `postgres:18.4-alpine3.24`, `redis:7.4.10-alpine3.21`, `nginx:1.29.8-alpine3.23`, and `certbot/certbot:v5.7.0`.
- Keep PostgreSQL authoritative. Redis uses AOF `everysec`/`noeviction` but remains disposable and excluded from backup.
- Keep API, worker, migrate, importer, and admin commands separate; Asynq stays explicitly pre-1.0 at `v0.26.0`.
- Reuse Qlockify's root/backend/frontend `.env` shape and backup/restore script family with the differences recorded in the adaptation contract.
- Use structured logs and manual operator checks for MVP; add private Prometheus/Grafana only after MVP acceptance and before AI.
## Phase 0: planning baseline
- The Platform Operations spec, ADRs 0001–0003, Qlockify adaptation contract, bootstrap runbook, and backup/restore runbook define the approved behavior.
- Runtime values such as IP, DNS access, email, ParsPack credentials, and encryption passphrase are deliberately deferred to the operation that uses them.
- Backend/frontend command and variable names are integrated when their implementation contracts exist; earlier independent deployment work is not blocked.
- Stakeholder approval and explicit implementation authorization were recorded on 2026-08-20.
Checkpoint: the plan and tasks are approved; no implementation artifact exists yet.
## Phase 1: configuration and runtime foundation
1. Create the deployment-root example and redaction-safe validation for the Qlockify-shaped three-file layout; consume backend/frontend-owned examples without duplicating them.
2. Implement private PostgreSQL/Redis services, named volumes, health checks, authentication, resource limits, and bounded logs.
3. Add API/worker plus separate migration/import/admin profiles from one backend revision.
4. Add the ordinary frontend static build and font/static health checks.
Checkpoint: Compose renders from placeholder test values; only intended networks exist; ordinary startup cannot invoke migration/import/admin; no secret/font/runtime artifact is tracked.
## Phase 2: edge and first-start lifecycle
5. Adapt Qlockify Nginx routing for the single canonical domain, API, frontend, readiness, and exact SSE behavior.
6. Adapt Qlockify Certbot issuance/renewal/reload for the single domain and pinned image.
7. Add guarded first-start orchestration for data readiness, migration, one-time import/reconciliation, and first public activation.
Checkpoint: routing/TLS configuration validates, first-start fails closed before public activation, and later ordinary startup never imports.
## Phase 3: releases and rollback
8. Define the ignored release-record format and exclusive operation lock.
9. Adapt Qlockify's deploy flow to verify manually pulled clean revisions, build/migrate/replace services, observe health, and activate one three-hash release.
10. Implement prior-release checkout/rebuild rollback with migration compatibility guards.
Checkpoint: induced checkout/build/migration/readiness failures preserve the active stack; a prior application release is recoverable within 15 minutes under normal conditions.
## Phase 4: backup and restore
11. Adapt Qlockify's plain-SQL PostgreSQL backup to emit only database data plus a non-secret manifest/checksums.
12. Adapt its OpenSSL/rclone upload and encrypted 3-local/7-remote retention flow for the ZarinPal ParsPack prefix.
13. Harden local restore with explicit backup, clean-target, compatibility, and production confirmation guards.
14. Adapt S3 download/decrypt to verify an explicit remote object and hand off to the guarded restore.
Checkpoint: excluded content inspection passes, failed upload/retention cannot delete the last good copy, and a clean rehearsal meets RPO 24 hours/RTO two hours.
## Phase 5: first-server MVP acceptance
15. Follow the bootstrap runbook on the prepared Ubuntu server, fill real environment values, deploy/import once, verify the application, rehearse rollback/restore, and record evidence.
Checkpoint: all MVP criteria pass. The stakeholder explicitly marks MVP ready before monitoring work begins.
## Phase 6: required observability before AI
16. Add private Prometheus and approved exporters/instrumentation with bounded labels, retention, and resource use.
17. Add private Grafana dashboards, release correlation, symptom-based alerts, runbook links, and tested delivery.
Checkpoint: an induced failure is detected and diagnosed from telemetry; the stakeholder approves the observability gate before AI begins.
## Verification strategy
- Documentation/planning: Markdown diff checks, source mapping review, secret-pattern scan, and no implementation files.
- Configuration: placeholder-only rendering, missing/invalid/unknown variable tests, permission/exclusion checks, and no resolved-value output.
- Compose: configuration validation, service health, network/port inspection, resource/log limits, profiles, and restart/Redis-loss behavior.
- Shell operations: ShellCheck plus disposable fixture tests for exact paths, locks, dirty/diverged Git, failure cleanup, retention, and restore guard matrices.
- Edge: `nginx -t`, canonical/unknown-host behavior, security headers, TLS issuance/renewal, static/font behavior, API routing, and streamed SSE checks.
- Release/recovery: induced deployment failures, timed prior-hash rollback, backup inspection, remote verification, and clean-database restore rehearsal.
- MVP/observability: manual acceptance record first; monitoring target/cardinality/dashboard/alert tests only after the MVP gate.
## Risks and mitigations
| Risk | Impact | Mitigation |
|---|---|---|
| Qlockify-specific behavior is copied blindly | Wrong services or unsafe operations | File-by-file adaptation matrix and explicit retain/change/drop review |
| Environment ownership drifts | Secret mismatch or accidental client exposure | Root/backend/frontend ownership contract; one owner per variable; redaction-safe validation |
| Manual releases select inconsistent source | Broken stack | Interactive pulls first, one lock, three clean SHA checks, one release record |
| Server-side builds vary over time | Difficult reproduction | Pin infrastructure/dependency inputs and record three SHAs plus local image ids |
| Migration prevents rollback | Extended outage | Expand/contract sequencing, preflight backup, no automatic down migration |
| Backup contains secrets or is unusable | Disclosure or data loss | PostgreSQL-only package, encryption/checksums, explicit exclusions, clean restore rehearsal |
| Redis loss interrupts analytics | Temporary missing results | AOF `everysec`, PostgreSQL authority, idempotent reconciliation and cold-cache tests |
| 2-GB server exhausts memory | OOM/restarts | Approved per-service limits, 2-GB swap, bounded imports/logs, capacity upgrade when measured |
| Monitoring is postponed too far | Weak diagnosis before AI | Hard MVP-to-observability-to-AI phase gate |
## Parallel and sequential work
- After environment/paths are fixed, PostgreSQL/Redis foundation, frontend build, Nginx fixtures, and backup fixtures can be prepared in separate focused slices.
- Compose integration, migration/import, release activation, rollback, production restore, DNS/TLS cutover, and phase gates remain sequential.
- Backend/frontend repositories define their application commands and variables; deployment consumes those contracts without editing those repositories in this workstream.
## Completion
- Every authorized task in `tasks/todo.md` passes its acceptance and verification.
- The active stack maps to exact deployment/backend/frontend commits and can roll back safely.
- Only Nginx publishes application ports; secrets/runtime/raw/font copies remain outside deployment Git.
- Encrypted off-host backup and clean restore meet their RPO/RTO.
- Prometheus/Grafana acceptance passes after MVP and before AI.

348
tasks/todo.md Normal file
View File

@@ -0,0 +1,348 @@
# Platform Operations implementation tasks
- Status: Approved — Phase 4 implementation authorized 2026-08-20
- Canonical plan: `tasks/plan.md`
- Adaptation baseline: `docs/QLOCKIFY-ADAPTATION-CONTRACT.md`
## Task 0: Approve the implementation plan
**Description:** Confirm that the Qlockify adaptation contract, dependency order, task boundaries, and verification gates are the correct basis for later implementation.
**Acceptance criteria:**
- [x] Platform Operations specification and ADRs 0001–0003 are approved.
- [x] Qlockify flow/environment mapping is documented without real runtime values.
- [x] Stakeholder approved this plan/task breakdown and authorized implementation on 2026-08-20.
**Verification:** Documentation diff review and confirmation that no implementation artifact was created.
**Dependencies:** None
**Files likely touched:** documentation only
**Estimated scope:** Small
## Task 1: Add the deployment environment contract
**Description:** Add the root deployment example and redaction-safe validation for the Qlockify-shaped root/backend/frontend environment layout. Backend and frontend examples remain owned by their repositories and are consumed without duplication.
**Acceptance criteria:**
- [ ] The root example contains only deployment-owned safe defaults/blank placeholders; every service-owned variable has one documented owner.
- [ ] Real `.env` files stay ignored, mode `0600`, excluded from backup, and absent from rendered/logged output.
- [ ] Validation rejects missing, unknown, malformed, or client-exposed secret variables for the selected operation.
**Verification:** Placeholder render, negative validation matrix, Git-ignore/permission checks, and staged secret scan.
**Dependencies:** Task 0 plus explicit implementation authorization
**Files likely touched:** `.env.example`, validation helper/test, documentation; maximum 5 files
**Estimated scope:** Medium
## Task 2: Add PostgreSQL and Redis Compose foundation
**Description:** Define pinned private data services, health checks, named volumes, authentication, resource limits, bounded logs, and Redis AOF behavior.
**Acceptance criteria:**
- [ ] PostgreSQL and Redis become healthy using the approved pins and memory limits.
- [ ] Neither publishes a host port; only approved services can join the private data network.
- [ ] Redis uses AOF `everysec`, `noeviction`, 192-MB maxmemory, and remains excluded from backup.
**Verification:** Compose validation, health/port/network/volume/resource inspection, restart and Redis-loss tests.
**Dependencies:** Task 1
**Files likely touched:** `compose.yaml`, data config, focused validation/test files; maximum 5 files
**Estimated scope:** Medium
## Task 3: Add backend runtime boundaries
**Description:** Wire API and worker services plus one-off migrate/import/admin profiles from the same recorded backend checkout/image lineage.
**Acceptance criteria:**
- [ ] API and worker use private networks, health/readiness, graceful shutdown, and approved resource limits.
- [ ] Migrate/import/admin are explicit one-off profiles and never run during ordinary startup.
- [ ] Worker uses pre-1.0 Asynq `v0.26.0` with concurrency 16 globally and 4 per merchant.
**Verification:** Compose profile/startup/failure/shutdown tests and service command-contract review.
**Dependencies:** Task 2 and the backend-owned command contract
**Files likely touched:** `compose.yaml`, backend image definition, lifecycle tests/docs; maximum 5 files
**Estimated scope:** Medium
## Task 4: Add frontend production build
**Description:** Build and serve the direct frontend checkout using its ordinary static context and same-origin API configuration.
**Acceptance criteria:**
- [ ] Build consumes frontend commit `64f5ac2` or later and its tracked font/license normally.
- [ ] Static sentinel, SPA fallback, and emitted font resource/content type pass.
- [ ] Deployment Git contains no font/license copy, fetch, credentials, or alternate asset path.
**Verification:** Production build/static-server checks plus repository-policy scan.
**Dependencies:** Task 1 and the frontend-owned build contract
**Files likely touched:** frontend image definition, `compose.yaml`, focused health/tests; maximum 5 files
**Estimated scope:** Medium
## Checkpoint A: Runtime foundation
- [ ] Tasks 1–4 pass from clean nested checkouts with placeholder test values.
- [ ] Normal startup cannot invoke migration/import/admin.
- [ ] No secrets, runtime state, raw data, or duplicated font assets are tracked.
## Task 5: Adapt Qlockify Nginx routing
**Description:** Adapt the Qlockify template/entrypoint for the single canonical domain, default-host rejection, frontend/API routing, security headers, health, caching, and SSE.
**Acceptance criteria:**
- [ ] Only ports 80/443 publish; HTTP redirects exactly to `zarinpal.amiirkhl.ir`; unknown hosts reject.
- [ ] API and frontend routing/fallback do not overlap and public readiness remains generic.
- [ ] Exact SSE route preserves auth/event ids and streams heartbeats without buffering, cache, compression, or premature timeout.
**Verification:** `nginx -t`, route/header/default-host tests, static/font test, and streamed SSE fixture.
**Dependencies:** Tasks 3–4
**Files likely touched:** Nginx template, image/entrypoint, focused tests; maximum 5 files
**Estimated scope:** Medium
## Task 6: Adapt Qlockify Certbot lifecycle
**Description:** Adapt Qlockify's HTTP-01 bootstrap, pinned Certbot service, certificate watcher, renewal, and Nginx reload for the single domain.
**Acceptance criteria:**
- [ ] Missing production certificate uses only the bounded bootstrap certificate path until issuance.
- [ ] Staging issuance, production issuance, renewal, and validated Nginx reload pass.
- [ ] No alternate domain, local production certificate mode, private key, or ACME state enters Git.
**Verification:** Compose/entrypoint tests, staging ACME drill, certificate name/expiry/protocol inspection.
**Dependencies:** Task 5
**Files likely touched:** Certbot entrypoint/config, `compose.yaml`, focused tests/docs; maximum 5 files
**Estimated scope:** Medium
## Task 7: Add guarded first-start orchestration
**Description:** Add the explicit first-server path that starts data services, migrates, imports/reconciles the pinned CSV, and activates public services only after success.
**Acceptance criteria:**
- [ ] Input is exact/read-only and migration/import/reconciliation failures prevent public activation.
- [ ] A completed identical import is a safe no-op; recovery never guesses a partial CSV position.
- [ ] Ordinary deploy/start/reboot paths never invoke first-start, importer, or admin jobs.
**Verification:** Success, checksum/schema/count, interruption/recovery, idempotency, and ordinary-start negative tests.
**Dependencies:** Tasks 2–6 and approved backend importer commands
**Files likely touched:** `scripts/first-start.sh`, Compose profile, focused tests/docs; maximum 5 files
**Estimated scope:** Medium
## Checkpoint B: Edge and first-start
- [ ] Nginx/TLS/static/API/SSE checks pass.
- [ ] First-start is fail-closed and ordinary startup never imports.
- [ ] Only Nginx is externally reachable.
## Task 8: Add release records and operation lock
**Description:** Define ignored current/previous/timestamped release records and one exclusive lock shared by deploy, rollback, restore, migration, and import as required.
**Acceptance criteria:**
- [ ] Record captures three SHAs, local image ids, migration, backup, operator, health, outcome, and previous release without secrets.
- [ ] Invalid/incomplete records cannot become active and current/previous updates are atomic.
- [ ] Conflicting operations fail safely without stale-lock ambiguity.
**Verification:** Schema/parser tests, invalid-record matrix, lock contention/stale-owner tests, and redaction scan.
**Dependencies:** Task 1
**Files likely touched:** release schema/helper/tests/docs; maximum 5 files
**Estimated scope:** Medium
## Task 9: Adapt Qlockify manual deployment
**Description:** Adapt Qlockify's deploy structure to verify three manually pulled repositories, build/migrate/replace services, observe health, and activate one coherent release.
**Acceptance criteria:**
- [ ] Script performs no Git fetch/pull/reset and rejects wrong path/remote/branch/SHA/dirty state.
- [ ] Build, migration, readiness, font, worker, or SSE failure leaves the active release unchanged and records evidence.
- [ ] Successful deployment records all three SHAs and passes a 15-minute postflight observation before activation.
**Verification:** ShellCheck, disposable Git fixtures, Compose mocks/integration, and induced failure matrix.
**Dependencies:** Tasks 5–8
**Files likely touched:** `scripts/deploy.sh`, release helper, focused tests/runbook; maximum 5 files
**Estimated scope:** Medium
## Task 10: Add three-hash rollback
**Description:** Select the previous release record, verify its three revisions, rebuild, and run the normal postflight without automatic database down-migration.
**Acceptance criteria:**
- [ ] Exact previous hashes and clean repository guards precede checkout/build.
- [ ] Migration incompatibility stops rollback with the current stack preserved.
- [ ] Normal application rollback completes within 15 minutes and is recorded as a new event.
**Verification:** Git fixture tests, incompatible-migration negative test, and timed rollback drill.
**Dependencies:** Task 9
**Files likely touched:** rollback script/helper/tests/runbook; maximum 5 files
**Estimated scope:** Medium
## Checkpoint C: Release safety
- [ ] Dirty/diverged/manual-pull/build/migration/readiness failures preserve the active stack.
- [ ] Active and previous releases map to exact three-repository revisions.
- [ ] Timed rollback meets its target without a down migration.
## Task 11: Adapt Qlockify PostgreSQL backup
**Description:** Retain Qlockify's matching-client plain-SQL dump flow while packaging only PostgreSQL plus a non-secret release/migration/checksum manifest.
**Acceptance criteria:**
- [ ] Dump nonzero exit, empty output, invalid header, or checksum failure prevents package success.
- [ ] Package contains only plain SQL and the approved non-secret manifest/checksums.
- [ ] `.env`, secrets, Redis, TLS, CSV, credentials, logs, monitoring, font/license, and media are absent.
**Verification:** Fixture database dump, package listing/header/checksum checks, and forbidden-content scan.
**Dependencies:** Tasks 2, 8
**Files likely touched:** `scripts/backup.sh`, manifest helper/tests/runbook; maximum 5 files
**Estimated scope:** Medium
## Task 12: Adapt encrypted ParsPack upload and retention
**Description:** Retain Qlockify's OpenSSL AES-256-CBC PBKDF2-200k and rclone structure while keeping three encrypted local and seven encrypted remote verified backups.
**Acceptance criteria:**
- [ ] Upload uses a private temporary key and is finalized only after remote verification.
- [ ] Failed encryption/upload/verification preserves all previous copies and no plaintext backup remains durably.
- [ ] Retention removes only exact stale targets after new-copy verification and never deletes the last good copy.
**Verification:** Fake S3/ParsPack-compatible fixture, failure/retention matrix, checksum and plaintext-residue checks.
**Dependencies:** Task 11
**Files likely touched:** upload script, retention helper/tests/runbook; maximum 5 files
**Estimated scope:** Medium
## Task 13: Add guarded local restore
**Description:** Restore an explicitly selected verified backup into a new empty target using `psql -X --set ON_ERROR_STOP=on` and independent production guards.
**Acceptance criteria:**
- [ ] Provenance/checksum/header/version/free-space/exact-empty-target checks precede mutation.
- [ ] Every missing production factor aborts before writers stop or a database is changed.
- [ ] Restore never changes `.env`, Redis, TLS, CSV, credentials, logs, or frontend assets.
**Verification:** Disposable database restore, negative guard matrix, partial-SQL failure, and exclusion checks.
**Dependencies:** Task 11
**Files likely touched:** `scripts/restore.sh`, guard/helper/tests/runbook; maximum 5 files
**Estimated scope:** Medium
## Task 14: Adapt encrypted S3 restore handoff
**Description:** Download an explicit encrypted object, verify/decrypt it in a restrictive temporary directory, and hand it to the guarded local restore.
**Acceptance criteria:**
- [ ] Production requires an explicit object id; an implicit `latest` cannot select a production backup.
- [ ] Remote identity/size/checksum and decrypted package checks pass before restore invocation.
- [ ] Temporary encrypted/plaintext material is removed only from the exact validated temporary directory.
**Verification:** Fake S3 object-selection/tamper tests, decryption failure tests, and guarded handoff rehearsal.
**Dependencies:** Tasks 12–13
**Files likely touched:** `scripts/restore-from-s3.sh`, focused tests/runbook; maximum 4 files
**Estimated scope:** Medium
## Checkpoint D: Recovery
- [ ] Daily schedule/3-local/7-remote retention behaves safely.
- [ ] Forbidden content is absent from every backup package.
- [ ] Clean restore passes schema/count/tenant/login/cold-cache/worker/SSE validation within two hours.
## Task 15: Bootstrap and accept the MVP server
**Description:** Execute the reviewed runbooks on the prepared Ubuntu server, fill runtime values, perform first-start, activate the release, and rehearse rollback/restore.
**Acceptance criteria:**
- [ ] Host access/firewall/swap/Docker/DNS/TLS and only-80/443 exposure checks pass.
- [ ] Migration/import/accounts/tenant isolation/worker/SSE/frontend font and manual health/log checks pass.
- [ ] Three-hash release, rollback, encrypted remote backup, and clean restore meet their time/recovery targets.
**Verification:** Signed non-secret bootstrap/MVP acceptance record.
**Dependencies:** Checkpoints A–D and a prepared server/runtime values
**Files likely touched:** ignored server evidence/release records only
**Estimated scope:** Operational session
## Checkpoint E: MVP ready
- [ ] Every MVP acceptance criterion passes.
- [ ] Active/previous releases and backup/restore are proven.
- [ ] Stakeholder explicitly opens the observability phase.
## Task 16: Add private Prometheus collection
**Description:** Add post-MVP application, worker/SSE, PostgreSQL/Redis, host/container, backup/restore, and TLS collection with bounded labels and retention.
**Acceptance criteria:**
- [ ] Required signals and target health pass within approved resource/retention limits.
- [ ] Prometheus/exporters are private and not routed by Nginx.
- [ ] Merchant/user/request/card identifiers are absent from metric labels.
**Verification:** Target/signal/cardinality/resource tests and sensitive-label scan.
**Dependencies:** Checkpoint E
**Files likely touched:** split into focused Prometheus/exporter slices of no more than 5 files
**Estimated scope:** Multiple Medium slices
## Task 17: Add private Grafana dashboards and alerts
**Description:** Add post-MVP dashboards, release correlation, actionable symptom alerts, runbook links, and tested operator delivery.
**Acceptance criteria:**
- [ ] Service, worker/SSE, data, host, backup, and TLS dashboards answer the approved operator questions.
- [ ] Every page/ticket alert is actionable, bounded, linked to a runbook, and tested through an induced failure.
- [ ] Grafana remains private and contains no sensitive labels, logs, or embedded credentials.
**Verification:** Dashboard review, induced failure, alert test-fire, access and redaction checks.
**Dependencies:** Task 16
**Files likely touched:** split into focused dashboard/alert slices of no more than 5 files
**Estimated scope:** Multiple Medium slices
## Final observability checkpoint before AI
- [ ] Prometheus/Grafana acceptance criteria pass after MVP.
- [ ] Alert delivery and runbook ownership are proven.
- [ ] Stakeholder explicitly approves beginning AI implementation.
- [ ] No CI/CD pipeline or automated deployment system was introduced.

112
tests/local-dev.tests.ps1 Normal file
View File

@@ -0,0 +1,112 @@
$ErrorActionPreference = 'Stop'
$modulePath = Join-Path $PSScriptRoot '..\scripts\LocalDev.psm1'
Import-Module $modulePath -Force
function Assert-Equal {
param($Expected, $Actual, [string]$Message)
if ($Expected -ne $Actual) {
throw "$Message (expected '$Expected', got '$Actual')"
}
}
function Assert-Throws {
param([scriptblock]$Action, [string]$Pattern, [string]$Message)
try {
& $Action
}
catch {
if ($_.Exception.Message -notmatch $Pattern) {
throw "$Message (unexpected error: $($_.Exception.Message))"
}
return
}
throw "$Message (no error was thrown)"
}
$fixture = Join-Path ([System.IO.Path]::GetTempPath()) ("zarinpal-local-dev-" + [guid]::NewGuid())
New-Item -ItemType Directory -Path $fixture | Out-Null
try {
$validEnv = Join-Path $fixture '.env'
@(
'# ignored comment'
'PLAIN=value'
'QUOTED="value with spaces"'
'EMPTY='
) | Set-Content -LiteralPath $validEnv -Encoding utf8
$values = Read-DotEnvFile -Path $validEnv
Assert-Equal 'value' $values.PLAIN 'Plain dotenv values should parse'
Assert-Equal 'value with spaces' $values.QUOTED 'Quoted dotenv values should parse'
Assert-Equal '' $values.EMPTY 'Blank dotenv values should parse'
$duplicateEnv = Join-Path $fixture 'duplicate.env'
@('KEY=one', 'KEY=two') | Set-Content -LiteralPath $duplicateEnv -Encoding utf8
Assert-Throws { Read-DotEnvFile -Path $duplicateEnv } 'duplicate variable' 'Duplicate variables must fail closed'
$invalidEnv = Join-Path $fixture 'invalid.env'
'NOT A KEY=value' | Set-Content -LiteralPath $invalidEnv -Encoding utf8
Assert-Throws { Read-DotEnvFile -Path $invalidEnv } 'invalid dotenv entry' 'Malformed variables must fail closed'
$backend = Join-Path $fixture 'backend'
$frontend = Join-Path $fixture 'frontend'
New-Item -ItemType Directory -Path (Join-Path $backend 'cmd\api') -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $backend 'cmd\worker') -Force | Out-Null
New-Item -ItemType Directory -Path (Join-Path $backend 'cmd\scheduler') -Force | Out-Null
New-Item -ItemType Directory -Path $frontend -Force | Out-Null
'{"scripts":{"dev":"vite"}}' | Set-Content -LiteralPath (Join-Path $frontend 'package.json') -Encoding utf8
$commands = Resolve-LocalDevCommands -BackendPath $backend -FrontendPath $frontend
Assert-Equal './cmd/api' $commands.Api.Arguments[1] 'API command should follow the backend contract'
Assert-Equal './cmd/worker' $commands.Worker.Arguments[1] 'Worker command should follow the backend contract'
Assert-Equal './cmd/scheduler' $commands.Scheduler.Arguments[1] 'Scheduler command should follow the backend contract'
Assert-Equal 'run' $commands.Frontend.Arguments[0] 'Frontend should use its checked-in dev script'
$stateDirectory = Join-Path $fixture '.local'
New-Item -ItemType Directory -Path $stateDirectory -Force | Out-Null
$currentProcess = Get-Process -Id $PID
@([pscustomobject]@{
Name = 'test-host'
Pid = $currentProcess.Id
StartTimeUtc = $currentProcess.StartTime.ToUniversalTime().ToString('o')
}) | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $stateDirectory 'processes.json') -Encoding utf8
$status = @(Get-LocalDevStatus -DeploymentPath $fixture)
Assert-Equal $true $status[0].Running 'Status should recognize a recorded live process after JSON date conversion'
$childTreeRoot = Start-Process -FilePath 'cmd.exe' -ArgumentList @('/c', 'powershell.exe -NoProfile -Command "Start-Sleep -Seconds 60"') -WindowStyle Hidden -PassThru
try {
$childTreeRoot.Refresh()
@([pscustomobject]@{
Name = 'test-tree'
Pid = $childTreeRoot.Id
StartTimeUtc = $childTreeRoot.StartTime.ToUniversalTime().ToString('o')
}) | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $stateDirectory 'processes.json') -Encoding utf8
$deadline = [DateTime]::UtcNow.AddSeconds(5)
do {
$child = Get-CimInstance Win32_Process -Filter "ParentProcessId=$($childTreeRoot.Id)" -ErrorAction SilentlyContinue | Select-Object -First 1
if (-not $child) { Start-Sleep -Milliseconds 50 }
} while (-not $child -and [DateTime]::UtcNow -lt $deadline)
if (-not $child) { throw 'Test child process did not start' }
Stop-LocalDev -DeploymentPath $fixture
Assert-Equal $null (Get-Process -Id $childTreeRoot.Id -ErrorAction SilentlyContinue) 'Stop should terminate the recorded wrapper process'
Assert-Equal $null (Get-Process -Id $child.ProcessId -ErrorAction SilentlyContinue) 'Stop should terminate descendants of the recorded wrapper process'
}
finally {
if (Get-Process -Id $childTreeRoot.Id -ErrorAction SilentlyContinue) {
& taskkill.exe /PID $childTreeRoot.Id /T /F | Out-Null
}
}
Remove-Item -LiteralPath (Join-Path $backend 'cmd\worker') -Recurse
Assert-Throws { Resolve-LocalDevCommands -BackendPath $backend -FrontendPath $frontend } 'cmd/worker' 'A missing worker entrypoint must be actionable'
Write-Output 'local-dev tests passed'
}
finally {
Remove-Item -LiteralPath $fixture -Recurse -Force -ErrorAction SilentlyContinue
}