Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d43cbfa44e | |||
| 573d2e5d35 | |||
| 076ca2d590 | |||
| 1f340c1aa1 |
@@ -254,30 +254,16 @@ jobs:
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
git pull
|
git pull
|
||||||
|
|
||||||
- name: Sync .env files from .env.example
|
- name: Inject .env files from Gitea variables
|
||||||
working-directory: /docker/FarmaFinder
|
working-directory: /docker/FarmaFinder
|
||||||
|
env:
|
||||||
|
GITEA_TOKEN: ${{ vars.GITEA_TOKEN }}
|
||||||
|
GITEA_OWNER: Ichitux
|
||||||
|
GITEA_REPO: FarmaFinder
|
||||||
|
WORK_DIR: /docker/FarmaFinder
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
while IFS= read -r env_example; do
|
bash scripts/deploy-env.sh
|
||||||
dir=$(dirname "$env_example")
|
|
||||||
env_file="$dir/.env"
|
|
||||||
|
|
||||||
if [ ! -f "$env_file" ]; then
|
|
||||||
cp "$env_example" "$env_file"
|
|
||||||
echo "[env-sync] Created $env_file from $env_example"
|
|
||||||
else
|
|
||||||
while IFS= read -r line || [ -n "$line" ]; do
|
|
||||||
case "$line" in
|
|
||||||
''|\#*) continue ;;
|
|
||||||
esac
|
|
||||||
key=$(echo "$line" | cut -d'=' -f1)
|
|
||||||
if ! grep -qF "$key=" "$env_file"; then
|
|
||||||
echo "$line" >> "$env_file"
|
|
||||||
echo "[env-sync] Added missing key '$key' to $env_file"
|
|
||||||
fi
|
|
||||||
done < "$env_example"
|
|
||||||
fi
|
|
||||||
done < <(find apps -name ".env.example" -type f)
|
|
||||||
|
|
||||||
- name: Deploy containers
|
- name: Deploy containers
|
||||||
working-directory: /docker/FarmaFinder
|
working-directory: /docker/FarmaFinder
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"pid":568607,"startedAt":1784046408782}
|
{"pid":3854791,"startedAt":1784736547988}
|
||||||
@@ -184,6 +184,44 @@ cp .env.example .env
|
|||||||
docker compose up --build
|
docker compose up --build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Production Environment Variables (IMPORTANT)
|
||||||
|
|
||||||
|
The root `.env` file is the **source of truth** for all Docker Compose services. The `docker-compose.yml` uses `${VAR:?...}` syntax which reads from this file. **Do not use placeholder values in production** — the backend validates them on startup and will crash.
|
||||||
|
|
||||||
|
Required variables to set with real secrets:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# PostgreSQL password (used by postgres, backend, n8n, exporters)
|
||||||
|
PG_PASSWORD=<strong-random-hex>
|
||||||
|
|
||||||
|
# Backend session secret (required, non-placeholder)
|
||||||
|
SESSION_SECRET=<strong-random-hex>
|
||||||
|
|
||||||
|
# Backend CORS origin (must be your real domain, not localhost)
|
||||||
|
CORS_ORIGIN=https://farmacias.hacecalor.net
|
||||||
|
|
||||||
|
# N8N admin password
|
||||||
|
N8N_PASSWORD=<strong-random-string>
|
||||||
|
|
||||||
|
# Parapharmacy API keys (required for product ingestion)
|
||||||
|
INGEST_API_KEY=<strong-random-hex>
|
||||||
|
ADMIN_API_KEY=<strong-random-hex>
|
||||||
|
```
|
||||||
|
|
||||||
|
Generate secrets with:
|
||||||
|
```bash
|
||||||
|
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||||
|
```
|
||||||
|
|
||||||
|
**After changing `PG_PASSWORD`**, you must reset the PostgreSQL volume:
|
||||||
|
```bash
|
||||||
|
docker compose down
|
||||||
|
docker volume rm farmafinder_postgres_data
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Then re-seed data (see "First Run" below).
|
||||||
|
|
||||||
### Services
|
### Services
|
||||||
|
|
||||||
| Service | URL | Description |
|
| Service | URL | Description |
|
||||||
@@ -201,10 +239,27 @@ docker compose up --build
|
|||||||
docker compose exec backend node create-admin.js
|
docker compose exec backend node create-admin.js
|
||||||
# Default: admin / admin123
|
# Default: admin / admin123
|
||||||
|
|
||||||
# Seed sample pharmacies
|
# Seed sample pharmacies (SQLite — for local dev)
|
||||||
docker compose exec backend node seed.js
|
docker compose exec backend node seed.js
|
||||||
|
|
||||||
|
# Seed parapharmacy products (requires INGEST_API_KEY)
|
||||||
|
# The n8n-init container handles this automatically on first run.
|
||||||
|
# To re-seed manually after a PostgreSQL reset:
|
||||||
|
docker compose exec parapharmacy-api node -e "
|
||||||
|
const http = require('http');
|
||||||
|
const fs = require('fs');
|
||||||
|
const seed = JSON.parse(fs.readFileSync('/home/node/seed.json','utf8'));
|
||||||
|
const body = JSON.stringify(seed);
|
||||||
|
const req = http.request('http://localhost:3002/api/products/bulk', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'x-service-key': process.env.INGEST_API_KEY, 'Content-Length': Buffer.byteLength(body) }
|
||||||
|
}, res => { let d=''; res.on('data',c=>d+=c); res.on('end',()=>console.log(d)); });
|
||||||
|
req.write(body); req.end();
|
||||||
|
"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
After a PostgreSQL volume reset, the n8n-init container will re-import workflows automatically. The backend re-creates its PG tables on startup (`initDatabase()` in `server.js`).
|
||||||
|
|
||||||
### N8N Setup
|
### N8N Setup
|
||||||
|
|
||||||
N8N auto-creates an admin account on first start:
|
N8N auto-creates an admin account on first start:
|
||||||
@@ -225,6 +280,16 @@ docker compose down
|
|||||||
docker compose down -v
|
docker compose down -v
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Reset PostgreSQL Only (keep other data)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose down
|
||||||
|
docker volume rm farmafinder_postgres_data
|
||||||
|
docker compose up -d
|
||||||
|
# n8n-init re-imports workflows; backend re-creates tables on startup
|
||||||
|
# Re-seed parapharmacy products (see First Run above)
|
||||||
|
```
|
||||||
|
|
||||||
## Manual Setup
|
## Manual Setup
|
||||||
|
|
||||||
### 1. Install Redis
|
### 1. Install Redis
|
||||||
@@ -325,13 +390,27 @@ See [Parapharmacy Documentation](docs/parapharmacy.md) for details.
|
|||||||
|
|
||||||
## Database Schema
|
## Database Schema
|
||||||
|
|
||||||
### SQLite Tables
|
In production (Docker), the backend uses **PostgreSQL**. In local dev without PG, it falls back to **SQLite**.
|
||||||
|
|
||||||
**pharmacies**: `id`, `name`, `address`, `phone`, `latitude`, `longitude`
|
### PostgreSQL Tables (Production)
|
||||||
|
|
||||||
|
**pharmacies**: `id`, `name`, `address`, `phone`, `latitude`, `longitude`, `opening_hours`
|
||||||
|
|
||||||
**pharmacy_medicines**: `id`, `pharmacy_id`, `medicine_nregistro`, `medicine_name`, `price`, `stock`
|
**pharmacy_medicines**: `id`, `pharmacy_id`, `medicine_nregistro`, `medicine_name`, `price`, `stock`
|
||||||
|
|
||||||
**users**: `id`, `username`, `password_hash`, `created_at`
|
**users**: `id`, `username`, `password_hash`, `is_admin`, `address`, `latitude`, `longitude`, `created_at`
|
||||||
|
|
||||||
|
**user_alerts**: `id`, `user_id`, `type`, `medicine_nregistro`, `title`, `detail`, `schedule`, `created_at`, `updated_at`
|
||||||
|
|
||||||
|
**push_subscriptions**: `id`, `user_id`, `medicine_nregistro`, `medicine_name`, `endpoint`, `p256dh`, `auth`, `created_at`
|
||||||
|
|
||||||
|
**push_subscriptions_pharmacy**: `id`, `user_id`, `medicine_nregistro`, `medicine_name`, `pharmacy_id`, `endpoint`, `p256dh`, `auth`, `created_at`
|
||||||
|
|
||||||
|
**expo_push_tokens**: `id`, `user_id`, `expo_token`, `created_at`
|
||||||
|
|
||||||
|
### SQLite Tables (Local Dev Fallback)
|
||||||
|
|
||||||
|
Same schema as above minus foreign key constraints and PostgreSQL-specific types.
|
||||||
|
|
||||||
### Redis Cache
|
### Redis Cache
|
||||||
|
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ const sessionConfig = {
|
|||||||
resave: false,
|
resave: false,
|
||||||
saveUninitialized: false,
|
saveUninitialized: false,
|
||||||
cookie: {
|
cookie: {
|
||||||
secure: process.env.NODE_ENV === 'production' || process.env.COOKIE_SECURE === 'true',
|
secure: process.env.COOKIE_SECURE !== 'false' && (process.env.NODE_ENV === 'production' || process.env.COOKIE_SECURE === 'true'),
|
||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
maxAge: 24 * 60 * 60 * 1000 // 24 hours
|
maxAge: 24 * 60 * 60 * 1000 // 24 hours
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
|
|||||||
# Copy package files
|
# Copy package files
|
||||||
COPY apps/parapharmacy-api/package*.json ./
|
COPY apps/parapharmacy-api/package*.json ./
|
||||||
|
|
||||||
|
#Run npm install to avoid npm ci issues
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
# Install dependencies from the committed lockfile (production only)
|
# Install dependencies from the committed lockfile (production only)
|
||||||
RUN npm ci --omit=dev
|
RUN npm ci --omit=dev
|
||||||
|
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY pyproject.toml ./
|
COPY apps/pip-platform/pyproject.toml ./
|
||||||
RUN pip install --no-cache-dir --upgrade pip && \
|
RUN pip install --no-cache-dir --upgrade pip && \
|
||||||
pip install --no-cache-dir .
|
pip install --no-cache-dir .
|
||||||
|
|
||||||
COPY . .
|
COPY apps/pip-platform/ .
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,11 @@ plugins = ["pydantic.mypy"]
|
|||||||
requires = ["hatchling"]
|
requires = ["hatchling"]
|
||||||
build-backend = "hatchling.build"
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"pip-audit>=2.10.1",
|
||||||
|
]
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel]
|
[tool.hatch.build.targets.wheel]
|
||||||
packages = ["src"]
|
packages = ["src"]
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ class TestCircuitBreaker:
|
|||||||
return await coro
|
return await coro
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
result = asyncio.get_event_loop().run_until_complete(_run())
|
result = asyncio.run(_run())
|
||||||
assert result == "ok"
|
assert result == "ok"
|
||||||
|
|
||||||
def test_open_raises_service_unavailable(self):
|
def test_open_raises_service_unavailable(self):
|
||||||
@@ -42,7 +42,7 @@ class TestCircuitBreaker:
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
with pytest.raises(ServiceUnavailableException):
|
with pytest.raises(ServiceUnavailableException):
|
||||||
asyncio.get_event_loop().run_until_complete(_run())
|
asyncio.run(_run())
|
||||||
|
|
||||||
def test_half_open_after_recovery_timeout(self):
|
def test_half_open_after_recovery_timeout(self):
|
||||||
cb = CircuitBreaker(name="test", failure_threshold=1, recovery_timeout=0.01)
|
cb = CircuitBreaker(name="test", failure_threshold=1, recovery_timeout=0.01)
|
||||||
@@ -126,7 +126,7 @@ class TestWithRetry:
|
|||||||
nonlocal call_count
|
nonlocal call_count
|
||||||
call_count += 1
|
call_count += 1
|
||||||
if call_count < 3:
|
if call_count < 3:
|
||||||
raise Exception("temp fail")
|
raise ConnectionError("temp fail")
|
||||||
return "ok"
|
return "ok"
|
||||||
|
|
||||||
policy = RetryPolicy(max_attempts=3, base_delay=0.01, max_delay=0.1)
|
policy = RetryPolicy(max_attempts=3, base_delay=0.01, max_delay=0.1)
|
||||||
@@ -141,7 +141,7 @@ class TestWithRetry:
|
|||||||
async def _always_fail():
|
async def _always_fail():
|
||||||
nonlocal call_count
|
nonlocal call_count
|
||||||
call_count += 1
|
call_count += 1
|
||||||
raise Exception("always fail")
|
raise ConnectionError("always fail")
|
||||||
|
|
||||||
policy = RetryPolicy(max_attempts=2, base_delay=0.01, max_delay=0.1)
|
policy = RetryPolicy(max_attempts=2, base_delay=0.01, max_delay=0.1)
|
||||||
with pytest.raises(Exception, match="always fail"):
|
with pytest.raises(Exception, match="always fail"):
|
||||||
|
|||||||
Generated
+2532
File diff suppressed because it is too large
Load Diff
@@ -35,6 +35,7 @@ services:
|
|||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET must be set}
|
SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET must be set}
|
||||||
CORS_ORIGIN: ${CORS_ORIGIN:?CORS_ORIGIN must be set}
|
CORS_ORIGIN: ${CORS_ORIGIN:?CORS_ORIGIN must be set}
|
||||||
|
COOKIE_SECURE: ${COOKIE_SECURE:-false}
|
||||||
REDIS_HOST: redis
|
REDIS_HOST: redis
|
||||||
REDIS_PORT: "6379"
|
REDIS_PORT: "6379"
|
||||||
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
|
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Dependency Hardening Baseline
|
||||||
|
|
||||||
|
**Baseline date:** 2026-07-22
|
||||||
|
**Branch:** `security/dependency-hardening-2026-07-22`
|
||||||
|
**Lockfile:** root `package-lock.json`, installed with `npm ci`
|
||||||
|
|
||||||
|
## Toolchain
|
||||||
|
|
||||||
|
- Node.js `v22.22.1` (the production Dockerfiles target Node 20 and 24)
|
||||||
|
- npm `9.2.0`
|
||||||
|
- Python `3.14.4`
|
||||||
|
- uv `0.5.9`
|
||||||
|
- Docker `29.6.1`
|
||||||
|
- Docker Compose `v5.3.1`
|
||||||
|
|
||||||
|
The local Node version produces an expected engine warning for `@zxing/library@0.23.0`, which requires Node 24 or newer. Production and CI must use the declared Node image/version rather than this local Node 22 runtime.
|
||||||
|
|
||||||
|
## Audit counts
|
||||||
|
|
||||||
|
Reports were generated online and saved outside the repository. Counts are advisory snapshots, not a substitute for the CI gate.
|
||||||
|
|
||||||
|
| Scope | Critical | High | Moderate | Low | Total |
|
||||||
|
|---|---:|---:|---:|---:|---:|
|
||||||
|
| Workspace root | 3 | 13 | 60 | 3 | 79 |
|
||||||
|
| Backend workspace | 1 | 10 | 41 | 2 | 54 |
|
||||||
|
| Parapharmacy API | 0 | 2 | 0 | 1 | 3 |
|
||||||
|
| Frontend workspace | 1 | 6 | 20 | 1 | 28 |
|
||||||
|
| Scraper workspace | 0 | 1 | 0 | 0 | 1 |
|
||||||
|
|
||||||
|
The parapharmacy and scraper reports resolve through the root workspace lockfile. Their package-local audit invocation fails without a package-local lockfile; the workspace-scoped audit is the authoritative current result.
|
||||||
|
|
||||||
|
## Direct dependency families selected for review
|
||||||
|
|
||||||
|
- Backend OpenTelemetry: `@opentelemetry/auto-instrumentations-node ^0.52.0`, exporters/SDKs primarily on `^0.55.0`, resources/API families on `^1.x`.
|
||||||
|
- Backend native dependencies: `bcrypt ^5.1.1`, `sqlite3 ^5.1.6`, and `connect-sqlite3 ^0.9.16`.
|
||||||
|
- Parapharmacy: `mongoose ^8.8.0`, `puppeteer ^22.0.0`; current installed Puppeteer is `22.15.0`.
|
||||||
|
- Scraper: `puppeteer ^24.40.0`, `puppeteer-extra ^3.3.6`, and stealth plugin `^2.11.2`.
|
||||||
|
- Frontend: `vite ^5.0.8`, `vitest ^1.6.0`, `vite-plugin-pwa ^1.3.0`, and Grafana Faro/OpenTelemetry packages on the `^1.x` family.
|
||||||
|
|
||||||
|
## Upgrade order and known risks
|
||||||
|
|
||||||
|
1. Resolve Python dependencies and audit them independently.
|
||||||
|
2. Pin runtime images and verify lockfile-enforced Docker builds.
|
||||||
|
3. Upgrade backend OpenTelemetry as one family, then native bcrypt/SQLite packages.
|
||||||
|
4. Upgrade parapharmacy Mongoose/`fast-uri` and scraper Puppeteer separately.
|
||||||
|
5. Upgrade frontend Vite/Rollup/esbuild/Vitest without enabling Vitest UI.
|
||||||
|
6. Re-run all audits and record any residual advisory with production reachability, mitigation, owner, and expiry/review date.
|
||||||
|
|
||||||
|
Do not run `npm audit fix --force`: the baseline reports breaking-version fixes for several families, including OpenTelemetry, SQLite, and Vite/Vitest. Each such upgrade requires its own compatibility test and commit.
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# deploy-env.sh — Fetch Gitea Actions variables and write .env files.
|
||||||
|
# Called by the CI/CD deploy step on the self-hosted runner.
|
||||||
|
#
|
||||||
|
# Required env vars (injected by Gitea Actions):
|
||||||
|
# GITEA_TOKEN — API token with repo read access
|
||||||
|
# GITEA_OWNER — repo owner (e.g. Ichitux)
|
||||||
|
# GITEA_REPO — repo name (e.g. FarmaFinder)
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# GITEA_TOKEN=xxx GITEA_OWNER=Ichitux GITEA_REPO=FarmaFinder ./scripts/deploy-env.sh
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
GITEA_URL="${GITEA_URL:-https://git.hacecalor.net}"
|
||||||
|
WORK_DIR="${WORK_DIR:-.}"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helper: fetch all repo variables from Gitea API (handles pagination)
|
||||||
|
# Returns JSON array on stdout
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
fetch_variables() {
|
||||||
|
local page=1 limit=50 all='[]'
|
||||||
|
while true; do
|
||||||
|
local resp
|
||||||
|
resp=$(curl -sf \
|
||||||
|
-H "Authorization: token ${GITEA_TOKEN}" \
|
||||||
|
"${GITEA_URL}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/actions/variables?limit=${limit}&page=${page}")
|
||||||
|
|
||||||
|
local count
|
||||||
|
count=$(echo "$resp" | python3 -c "import sys,json; print(len(json.load(sys.stdin)))")
|
||||||
|
|
||||||
|
all=$(python3 -c "
|
||||||
|
import sys, json
|
||||||
|
a = json.loads('''${all}''')
|
||||||
|
b = json.loads(sys.stdin.read())
|
||||||
|
print(json.dumps(a + b))
|
||||||
|
" <<< "$resp")
|
||||||
|
|
||||||
|
if [ "$count" -lt "$limit" ]; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
page=$((page + 1))
|
||||||
|
done
|
||||||
|
echo "$all"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helper: write a .env file from a JSON object of key-value pairs.
|
||||||
|
# Keys are sorted alphabetically. Comments from .env.example are preserved.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
write_env() {
|
||||||
|
local env_path="$1"
|
||||||
|
local json_obj="$2"
|
||||||
|
local example_path="${env_path}.example"
|
||||||
|
|
||||||
|
# If no example file exists, write a plain KEY=VALUE file
|
||||||
|
if [ ! -f "$example_path" ]; then
|
||||||
|
python3 -c "
|
||||||
|
import json, sys
|
||||||
|
obj = json.loads('''${json_obj}''')
|
||||||
|
for k in sorted(obj):
|
||||||
|
print(f'{k}={obj[k]}')
|
||||||
|
" > "$env_path"
|
||||||
|
echo "[env] Wrote $env_path (${#json_obj} bytes, no .example template)"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
# If .example exists, produce a file that preserves comments/order
|
||||||
|
# and fills in values from the JSON
|
||||||
|
python3 -c "
|
||||||
|
import json, sys, re
|
||||||
|
|
||||||
|
example_path = '''${example_path}'''
|
||||||
|
env_path = '''${env_path}'''
|
||||||
|
values = json.loads('''${json_obj}''')
|
||||||
|
|
||||||
|
with open(example_path) as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
|
||||||
|
output = []
|
||||||
|
written_keys = set()
|
||||||
|
for line in lines:
|
||||||
|
stripped = line.rstrip('\n')
|
||||||
|
|
||||||
|
# Pass through comments and blank lines
|
||||||
|
if stripped.startswith('#') or stripped.strip() == '':
|
||||||
|
output.append(stripped)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Parse KEY=VALUE lines
|
||||||
|
m = re.match(r'^([A-Za-z_][A-Za-z0-9_]*)=(.*)', stripped)
|
||||||
|
if m:
|
||||||
|
key, _ = m.group(1), m.group(2)
|
||||||
|
if key in values:
|
||||||
|
output.append(f'{key}={values[key]}')
|
||||||
|
written_keys.add(key)
|
||||||
|
else:
|
||||||
|
output.append(stripped) # keep example default
|
||||||
|
else:
|
||||||
|
output.append(stripped)
|
||||||
|
|
||||||
|
# Append any keys from values that weren't in .example
|
||||||
|
for k in sorted(values):
|
||||||
|
if k not in written_keys:
|
||||||
|
output.append(f'{k}={values[k]}')
|
||||||
|
|
||||||
|
with open(env_path, 'w') as f:
|
||||||
|
f.write('\n'.join(output) + '\n')
|
||||||
|
|
||||||
|
print(f'[env] Wrote {env_path} ({len(values)} vars)')
|
||||||
|
" 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Main
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
echo "=== deploy-env.sh ==="
|
||||||
|
echo "Fetching variables from Gitea: ${GITEA_OWNER}/${GITEA_REPO}"
|
||||||
|
|
||||||
|
VARS_JSON=$(fetch_variables)
|
||||||
|
VAR_COUNT=$(echo "$VARS_JSON" | python3 -c "import sys,json; print(len(json.load(sys.stdin)))")
|
||||||
|
echo "Found ${VAR_COUNT} variables"
|
||||||
|
|
||||||
|
# Group variables by prefix
|
||||||
|
# ROOT_* → root .env (strip prefix)
|
||||||
|
# BACKEND_* → apps/backend/.env (strip prefix)
|
||||||
|
# FRONTEND_* → apps/frontend/.env (strip prefix)
|
||||||
|
# PARAPHARMACY_* → apps/parapharmacy-api/.env (strip prefix)
|
||||||
|
# No prefix → root .env (backward compat with original vars)
|
||||||
|
|
||||||
|
ROOT_VARS=$(python3 -c "
|
||||||
|
import json, sys
|
||||||
|
vars = json.loads(sys.stdin.read())
|
||||||
|
# Keys that are CI-only and should NOT appear in application .env files
|
||||||
|
skip = {'GITEA_TOKEN'}
|
||||||
|
result = {}
|
||||||
|
for v in vars:
|
||||||
|
name = v['name']
|
||||||
|
value = v['data']
|
||||||
|
if name in skip:
|
||||||
|
continue
|
||||||
|
if name.startswith('ROOT_'):
|
||||||
|
result[name[5:]] = value # strip ROOT_ prefix
|
||||||
|
elif not any(name.startswith(p) for p in ['BACKEND_', 'FRONTEND_', 'PARAPHARMACY_']):
|
||||||
|
result[name] = value # unprefixed → root
|
||||||
|
print(json.dumps(result))
|
||||||
|
" <<< "$VARS_JSON")
|
||||||
|
|
||||||
|
BACKEND_VARS=$(python3 -c "
|
||||||
|
import json, sys
|
||||||
|
vars = json.loads(sys.stdin.read())
|
||||||
|
result = {}
|
||||||
|
for v in vars:
|
||||||
|
name = v['name']
|
||||||
|
if name.startswith('BACKEND_'):
|
||||||
|
result[name[8:]] = value = v['data']
|
||||||
|
print(json.dumps(result))
|
||||||
|
" <<< "$VARS_JSON")
|
||||||
|
|
||||||
|
FRONTEND_VARS=$(python3 -c "
|
||||||
|
import json, sys
|
||||||
|
vars = json.loads(sys.stdin.read())
|
||||||
|
result = {}
|
||||||
|
for v in vars:
|
||||||
|
name = v['name']
|
||||||
|
if name.startswith('FRONTEND_'):
|
||||||
|
result[name[9:]] = v['data']
|
||||||
|
print(json.dumps(result))
|
||||||
|
" <<< "$VARS_JSON")
|
||||||
|
|
||||||
|
PARAPHARMACY_VARS=$(python3 -c "
|
||||||
|
import json, sys
|
||||||
|
vars = json.loads(sys.stdin.read())
|
||||||
|
result = {}
|
||||||
|
for v in vars:
|
||||||
|
name = v['name']
|
||||||
|
if name.startswith('PARAPHARMACY_'):
|
||||||
|
result[name[13:]] = v['data']
|
||||||
|
print(json.dumps(result))
|
||||||
|
" <<< "$VARS_JSON")
|
||||||
|
|
||||||
|
# Write each .env file
|
||||||
|
echo ""
|
||||||
|
write_env "${WORK_DIR}/.env" "$ROOT_VARS"
|
||||||
|
write_env "${WORK_DIR}/apps/backend/.env" "$BACKEND_VARS"
|
||||||
|
write_env "${WORK_DIR}/apps/frontend/.env" "$FRONTEND_VARS"
|
||||||
|
write_env "${WORK_DIR}/apps/parapharmacy-api/.env" "$PARAPHARMACY_VARS"
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== All .env files written ==="
|
||||||
Reference in New Issue
Block a user