Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions devops/production/bin/deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -397,9 +397,9 @@ sync_runtime_env_from_infisical() {
python3 "${ROOT_DIR}/bin/validate-runtime-env.py" \
"${RUNTIME_ENV_TEMP_FILE}" \
--write-operation-envs "${ROOT_DIR}/env"
chmod 600 "${RUNTIME_ENV_TEMP_FILE}"
mv -f "${RUNTIME_ENV_TEMP_FILE}" "${RUNTIME_ENV_FILE}"
rm -f "${RUNTIME_ENV_TEMP_FILE}"
RUNTIME_ENV_TEMP_FILE=""
require_private_regular_file "${RUNTIME_ENV_FILE}"
}

validate_saved_slot_values() {
Expand Down
92 changes: 92 additions & 0 deletions devops/production/bin/test_validate_runtime_env.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import importlib.util
import subprocess
import tempfile
import unittest
from pathlib import Path


SCRIPT = Path(__file__).with_name("validate-runtime-env.py")
SPEC = importlib.util.spec_from_file_location("validate_runtime_env", SCRIPT)
if SPEC is None or SPEC.loader is None:
raise RuntimeError(f"Unable to load {SCRIPT}")
VALIDATOR = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(VALIDATOR)


class ValidateRuntimeEnvTest(unittest.TestCase):
password = "a" * 64

def values(self) -> dict[str, str]:
return {
**VALIDATOR.EXPECTED_VALUES,
"DATABASE_URL": (
"postgresql://opensyria_datasets_production:"
f"{'b' * 64}@infra-postgres:5432/"
"opensyria_datasets_production?schema=public"
),
"REDIS_URL": (
f"redis://:{self.password}@opensyria-production-redis:6379/0"
),
}

def run_validator(self, values: dict[str, str]) -> tuple[subprocess.CompletedProcess[str], Path]:
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
directory = Path(temporary.name)
source = directory / "source.env"
source.write_text(
"".join(f"{key}={value}\n" for key, value in sorted(values.items())),
encoding="utf-8",
)
result = subprocess.run(
[
"python3",
str(SCRIPT),
str(source),
"--write-operation-envs",
str(directory / "runtime"),
],
check=False,
capture_output=True,
text=True,
)
return result, directory

def test_sanitizes_matching_recovery_password(self) -> None:
values = self.values()
values["REDIS_PASSWORD"] = self.password

result, directory = self.run_validator(values)

self.assertEqual(result.returncode, 0, result.stderr)
runtime = (directory / "runtime" / "api.env").read_text(encoding="utf-8")
self.assertIn("REDIS_URL=", runtime)
self.assertNotIn("REDIS_PASSWORD=", runtime)

def test_accepts_already_sanitized_runtime_environment(self) -> None:
result, directory = self.run_validator(self.values())

self.assertEqual(result.returncode, 0, result.stderr)
self.assertTrue((directory / "runtime" / "api.env").is_file())

def test_rejects_mismatched_recovery_password(self) -> None:
values = self.values()
values["REDIS_PASSWORD"] = "c" * 64

result, _ = self.run_validator(values)

self.assertNotEqual(result.returncode, 0)
self.assertIn("does not match", result.stderr)

def test_rejects_other_unexpected_keys(self) -> None:
values = self.values()
values["UNEXPECTED_SECRET"] = "forbidden"

result, _ = self.run_validator(values)

self.assertNotEqual(result.returncode, 0)
self.assertIn("unexpected or forbidden keys", result.stderr)


if __name__ == "__main__":
unittest.main()
30 changes: 23 additions & 7 deletions devops/production/bin/validate-runtime-env.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,14 +40,16 @@
"THROTTLE_FREE_TIER_DAILY_LIMIT": "500",
"THROTTLE_FREE_TIER_DAILY_TTL_SECONDS": "86400",
}
EXPECTED_KEYS = set(EXPECTED_VALUES) | {"DATABASE_URL", "REDIS_URL"}
RUNTIME_KEYS = set(EXPECTED_VALUES) | {"DATABASE_URL", "REDIS_URL"}
OPTIONAL_SOURCE_KEYS = {"REDIS_PASSWORD"}
DATABASE_URL_PATTERN = re.compile(
r"postgresql://opensyria_datasets_production:[0-9a-f]{64}"
r"@infra-postgres:5432/opensyria_datasets_production\?schema=public"
)
REDIS_URL_PATTERN = re.compile(
r"redis://:[0-9a-f]{64}@opensyria-production-redis:6379/0"
r"redis://:(?P<password>[0-9a-f]{64})@opensyria-production-redis:6379/0"
)
REDIS_PASSWORD_PATTERN = re.compile(r"[0-9a-f]{64}")
DATASET_KEYS = {
"DATASETS_RELEASES_DIR",
"DATASETS_RELEASE_SOURCES_FILE",
Expand Down Expand Up @@ -93,9 +95,21 @@ def validate_database_url(value: str) -> None:
fail("DATABASE_URL does not target the isolated OpenSyria production database")


def validate_redis_url(value: str) -> None:
if not REDIS_URL_PATTERN.fullmatch(value):
def validate_redis_url(value: str) -> str:
match = REDIS_URL_PATTERN.fullmatch(value)
if not match:
fail("REDIS_URL does not target the dedicated OpenSyria production Redis database")
return match.group("password")


def validate_redis_password(values: dict[str, str], url_password: str) -> None:
password = values.get("REDIS_PASSWORD")
if password is None:
return
if not REDIS_PASSWORD_PATTERN.fullmatch(password):
fail("REDIS_PASSWORD is not a 64-character hexadecimal secret")
if password != url_password:
fail("REDIS_PASSWORD does not match the credential in REDIS_URL")


def write_subset(directory: Path, name: str, values: dict[str, str]) -> None:
Expand All @@ -120,6 +134,7 @@ def write_operation_envs(directory: Path, values: dict[str, str]) -> None:
fail("operation environment directory must be a real directory")
directory.chmod(0o700)

write_subset(directory, "api.env", {key: values[key] for key in RUNTIME_KEYS})
write_subset(directory, "migrate.env", {"DATABASE_URL": values["DATABASE_URL"]})
write_subset(directory, "datasets.env", {key: values[key] for key in DATASET_KEYS})
write_subset(
Expand Down Expand Up @@ -151,8 +166,8 @@ def main() -> None:
fail("the exported dotenv file must be a non-empty regular file")

values = parse_dotenv(path)
missing = sorted(EXPECTED_KEYS - set(values))
unexpected = sorted(set(values) - EXPECTED_KEYS)
missing = sorted(RUNTIME_KEYS - set(values))
unexpected = sorted(set(values) - RUNTIME_KEYS - OPTIONAL_SOURCE_KEYS)
if missing:
fail(f"missing keys: {', '.join(missing)}")
if unexpected:
Expand All @@ -165,7 +180,8 @@ def main() -> None:
fail(f"incorrect values for: {', '.join(sorted(mismatched))}")

validate_database_url(values["DATABASE_URL"])
validate_redis_url(values["REDIS_URL"])
redis_password = validate_redis_url(values["REDIS_URL"])
validate_redis_password(values, redis_password)

if len(sys.argv) == 4:
if sys.argv[2] != "--write-operation-envs":
Expand Down
7 changes: 7 additions & 0 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,13 +125,20 @@ DATABASE_ENABLED=true
DATABASE_REQUIRED=true
DATABASE_LOG_QUERIES=false
REDIS_URL=redis://:<encoded-password>@opensyria-production-redis:6379/0
REDIS_PASSWORD=<same-64-character-hex-password-as-REDIS_URL>
REDIS_ENABLED=true
REDIS_REQUIRED=true
CACHE_TTL_SECONDS=300
THROTTLE_FREE_TIER_DAILY_LIMIT=500
THROTTLE_FREE_TIER_DAILY_TTL_SECONDS=86400
```

`REDIS_PASSWORD` is retained in Infisical for the host recovery contract. The
deployment validates that it matches the credential embedded in `REDIS_URL`,
then omits the standalone password from `env/api.env` and every long-lived API
container. A restored, already-sanitized `env/api.env` therefore contains only
`REDIS_URL`.

`APP_RELEASE` is deliberately absent: Compose injects the full Git commit SHA
for each deployment. `GITHUB_TOKEN` is deliberately absent because only the
ephemeral sync job receives it. Percent-encode the database username/password
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"test:production-contract": "python3 -m unittest discover -s devops/production/bin -p 'test_*.py'",
"test:integration:db": "pnpm run db:migrate:deploy && node --experimental-vm-modules ./node_modules/jest/bin/jest.js --config ./test/jest-integration.json --runInBand",
"datasets:sync": "pnpm run build && node dist/cli/sync-dataset-releases.js",
"datasets:sync:prod": "node dist/cli/sync-dataset-releases.js",
Expand All @@ -54,7 +55,7 @@
"release:check:config": "node scripts/check-release.js --config-only",
"release:check:public-api-bridge": "node scripts/check-public-api-bridge.js",
"release:check:docker": "node scripts/check-release.js --docker-build",
"validate": "pnpm run db:generate && pnpm run check && pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run test:e2e && pnpm run build && pnpm run audit:dependencies",
"validate": "pnpm run db:generate && pnpm run check && pnpm run lint && pnpm run typecheck && pnpm run test && pnpm run test:e2e && pnpm run test:production-contract && pnpm run build && pnpm run audit:dependencies",
"prepare": "husky"
},
"dependencies": {
Expand Down