diff --git a/deploy/oci/fullstack/.gitignore b/deploy/oci/fullstack/.gitignore new file mode 100644 index 0000000..b802990 --- /dev/null +++ b/deploy/oci/fullstack/.gitignore @@ -0,0 +1,8 @@ +terraform/.terraform/ +terraform/.terraform.lock.hcl +terraform/*.tfstate +terraform/*.tfstate.* +terraform/terraform.tfvars +env/*.env +!env/*.env.example +artifacts/ diff --git a/deploy/oci/fullstack/README.md b/deploy/oci/fullstack/README.md new file mode 100644 index 0000000..1eec88c --- /dev/null +++ b/deploy/oci/fullstack/README.md @@ -0,0 +1,332 @@ +# ArchiveOS OCI Full-Stack Parity v1 + +This package prepares an isolated OCI canary for ArchiveOS source revision +`c2324df9a935e897fbe8be94068f862c4ac3956f`. It does not perform production +DNS cutover, merge to `main`, or enable a second active scheduler/collector. + +## Scope and exclusions + +Included: + +- immutable frontend, Node backend, and ArchiveOS AI images; +- PostgreSQL 16 with pgvector on attached persistent storage; +- Obsidian Vault, runtime queue, backup, and secret-loading contracts; +- private Compute placement behind an OCI Flexible Load Balancer; +- canary-safe Compose, systemd, Terraform, migration, verification, backup, + and rollback assets. + +Excluded: + +- Archive-World and its mounts/builds; +- Archive-lite commit `ebd5928`; +- Runtime timeline projection commit `554334a`; +- product UI/backend/AI changes; +- production DNS changes and production cutover. + +## Archive World Mini handoff boundary + +ArchiveOS never builds, edits, mounts, or commits the Archive-World repository. +The only accepted input is a World PM release handoff with an immutable OCIR +reference (`archive-world/mini@sha256:...`) or an equivalently immutable tar +artifact. Before a canary deployment, run: + +```powershell +.\deploy\oci\fullstack\scripts\verify-world-handoff.ps1 ` + -HandoffRoot ` + -MiniArtifactRoot ` + -WorldSourceHead <40-character-world-sha> ` + -ImageDigest +``` + +The handoff must contain the Mini viewer, `world-mini-map.json`, `status.json`, +the dated snapshot/current-state evidence, a read-only adapter manifest, and a +`provenance.json`. The provenance schema is `1.0.0` and must include the +required source/checksum fields plus additive `bundlePath` and `bundleSha256` +fields so the immutable viewer bundle can be verified. Its protection flags +must all remain `false`: `canonical`, `v3Applied`, `runtimeMutation`, and +`mainMerge`. + +The validator rejects missing source SHA, checksum mismatch, non-13-District +maps, invalid coordinates, negative scales, anchor mismatch, failed technical +status, or any protection-flag violation. `WORLD_MINI_VISUAL_PARTIAL` and +`WORLD_MINI_RELEASE_PARTIAL` are intentionally preserved; ArchiveOS runtime +health never upgrades World visual/release status. + +If `runtime-bindings.json` is not supplied by World PM and verified against the +same World source SHA and manifest SHA, the Mini viewer must show +`SPATIAL_BINDING_NOT_PROVIDED` and use only a whole-map/runtime-panel pulse. +ArchiveOS must not infer a mapping from its five logical Runtime areas to the +World's thirteen physical Districts. + +## Current OCI inventory + +The read-only survey on 2026-07-30 found: + +- region: `ap-osaka-1`; +- two existing VCNs and two public subnets; +- two running `VM.Standard.E2.1.Micro` instances with public IPs; +- no private subnet, NAT Gateway, Service Gateway, NSG, Load Balancer, + OCIR repository, Vault/Secret, attached Block Volume, Object Storage bucket, + or Dynamic Group suitable for this deployment. + +The existing Atlas and Studio micro instances are not reused. They are public, +resource-constrained, and owned by unrelated workloads. Provisioning the +resources in this package requires explicit cost approval. + +## Target architecture + +```text +Internet + -> OCI Flexible Load Balancer :443 + -> private Compute VM :8080 + -> frontend Nginx + -> backend:4000 + -> archiveos-ai:4100 + -> postgres:5432 + -> /srv/archiveos/vault + -> /srv/archiveos/world-handoff/archive-world-assets.json (read-only) + -> OpenAI HTTPS + -> archive-world-mini:4190 (private only) +``` + +Only frontend port 8080 is published on the VM, and its NSG accepts traffic +only from the Load Balancer NSG. Backend, AI, and PostgreSQL have no host +published ports. The World Mini container has no host port, database, source +repository mount, or Generated-root mount. The Compute VNIC has no public IP. + +The frontend's OCI-only Nginx configuration keeps `/api/*` on the ArchiveOS +backend and proxies the independent sibling route without removing its prefix: + +- `/archive-world-mini/*` -> `archive-world-mini:4190/archive-world-mini/*` +- `/archive-world-mini/status.json` and `world-mini-map.json` -> `no-store` +- hashed Mini assets -> immutable cache +- `/api/world/stream` -> HTTP/1.1 unbuffered SSE with cache disabled + +Canary access must use a private hostname or approved CIDR and returns +`X-Robots-Tag: noindex, nofollow` for Mini World content. Do not proceed to a +public production cutover without Load Balancer/WAF allowlisting, VPN, bastion, +or an approved authentication proxy. + +## Paid default proposal + +The Terraform defaults propose: + +- one `VM.Standard.E4.Flex`, 2 OCPU, 16 GB memory; +- 100 GB boot volume and 200 GB balanced Block Volume; +- one 10 Mbps Flexible Load Balancer; +- one private subnet, NAT Gateway, Service Gateway, and two NSGs; +- one private Object Storage backup bucket; +- OCI Certificates, Vault/Secrets, and instance-principal IAM. + +Using the public list rates as a planning estimate, Compute is approximately +USD 70.82/month and 300 GB balanced block/boot storage approximately +USD 12.75/month. The first 10 Mbps Flexible Load Balancer allocation may be +free when the tenancy is eligible. Object Storage, OpenAI usage, DNS, +certificate, backup growth, taxes, and account-specific Osaka adjustments are +additional. Confirm with the OCI Cost Estimator before `terraform apply`. + +## Immutable image flow + +Run from the clean source worktree: + +```powershell +.\deploy\oci\fullstack\scripts\build-images.ps1 +.\deploy\oci\fullstack\scripts\push-images.ps1 ` + -Region ap-osaka-1 ` + -Namespace +``` + +The scripts use only the full source SHA as the tag and emit digest references. +The VM must deploy `@sha256:` references; it must not build source. + +OCI repositories required: + +- `archiveos/frontend` +- `archiveos/backend` +- `archiveos/ai` + +## Secret Management + +Create `/etc/archiveos/secret-ocids.env` on the VM with root ownership and mode +`0600`. It maps variable names to OCI Secret OCIDs, not values: + +```text +DB_NAME=ocid1.vaultsecret... +DB_USER=ocid1.vaultsecret... +DB_PASSWORD=ocid1.vaultsecret... +OPENAI_API_KEY=ocid1.vaultsecret... +ARCHIVEOS_ADMIN_PASSWORD=ocid1.vaultsecret... +ARCHIVE_TOKEN_MARKET_TO_OS=ocid1.vaultsecret... +ARCHIVE_TOKEN_NEXUS_TO_OS=ocid1.vaultsecret... +ARCHIVE_TOKEN_LOGISTICS_TO_OS=ocid1.vaultsecret... +ARCHIVE_TOKEN_LEDGER_TO_OS=ocid1.vaultsecret... +ARCHIVE_TOKEN_OS_TO_LEDGER=ocid1.vaultsecret... +ARCHIVE_TOKEN_AUTHENTICATED_READ=ocid1.vaultsecret... +ARCHIVE_TOKEN_ADMIN_OPERATOR=ocid1.vaultsecret... +ARCHIVEOS_INTEGRATION_TOKEN=ocid1.vaultsecret... +``` + +Create `/etc/archiveos/archiveos.oci.conf` for non-secret values based on +`env/archiveos.oci.env.example`. The loader reads secrets with the Compute +instance principal and writes `/run/archiveos/archiveos.env` as root:root +`0600`. It removes the file when the service stops. + +The non-secret OCI configuration also requires the immutable World image +digest, World source HEAD, and adapter manifest SHA. The handoff manifest is +stored only at `/srv/archiveos/world-handoff/archive-world-assets.json` and +mounted read-only at `/world-handoff/archive-world-assets.json` into ArchiveOS +AI. + +Never place secret values in Git, Terraform variables/state outputs, cloud-init, +Docker build arguments, service unit files, or shell history. + +## Provisioning sequence + +1. Review `terraform plan`; do not apply without cost approval. +2. Provision the private network, Compute, storage, Load Balancer, bucket, + Dynamic Group, and policy. +3. Provision an OCI certificate and OCI Secret entries separately; Terraform + intentionally does not manage secret payloads. +4. Install the runtime and attach/mount Block Volume: + + ```bash + sudo deploy/oci/fullstack/scripts/install-runtime.sh + sudo deploy/oci/fullstack/scripts/mount-storage.sh /dev/oracleoci/oraclevdb + ``` + + Formatting requires the explicit `--format` switch and confirmation that + the device is the newly provisioned empty volume. + +5. Copy this exact source revision to `/opt/archiveos` for Compose and + operational assets only. Do not build on the VM. +6. Install the systemd units and secret mapping. +7. Load secrets and run `deploy-canary.sh`. + +## Data and Vault migration + +The source database remains read-only. Use: + +```powershell +.\deploy\oci\fullstack\scripts\migrate-data.ps1 -Mode Inventory +.\deploy\oci\fullstack\scripts\migrate-data.ps1 -Mode Dump +``` + +The dump is custom format, checksummed, transferred through SSH, and restored +only when `-ConfirmTargetRestore` is supplied. The restore target must be the +empty disposable canary database; the script does not drop or clean an existing +database. `Compare` checks core table row counts. Review Flyway history and +schema diffs before application startup. + +For Vault migration, produce a manifest without file contents: + +```bash +find /approved/source/vault -type f -printf '%P\0' | + sort -z | xargs -0 -r sha256sum --tag > vault.sha256 +``` + +Copy over an encrypted/private channel, regenerate the manifest under +`/srv/archiveos/vault`, and require exact path, count, byte, and SHA-256 match. +The Vault must not be committed or exposed publicly. + +## Canary safety + +The OCI overlay enforces: + +- `ARCHIVEOS_SCHEDULER_ENABLED=false` +- `ARCHIVE_LIVE_FLOW_COLLECTOR_ENABLED=false` +- `ARCHIVE_INTEGRATION_SAFE_MODE=true` +- `ARCHIVE_INTEGRATION_ALLOW_EXTERNAL_WRITE=false` +- `ARCHIVE_INTEGRATION_CALLBACK_ENABLED=false` +- `ARCHIVE_WORLD_ADAPTER_MODE=live` + +Runtime service endpoints must be approved private or HTTPS endpoints. Localhost, +`127.0.0.1`, and `host.docker.internal` are rejected by preflight. + +## Verification + +First run the product's unchanged local contract: + +```powershell +.\tools\runtime\verify-rag-e2e.ps1 +``` + +For OCI, inject credentials through process environment only and run: + +```powershell +.\deploy\oci\fullstack\scripts\verify-fullstack.ps1 ` + -BaseUrl https://archiveos-canary.example.com +``` + +The wrapper validates Public/PM/Admin boundaries, Managed Systems, pgvector, +real OpenAI Chat/Embedding checks, Obsidian sync, RAG search/ask evidence, +Spring Batch, RPA classification/decision records, Live Flow/SSE, and the +official correlation through the frontend/backend path. It prints no credential +values or RAG answer contents. It intentionally creates only canary validation +records, so run it exclusively against the disposable canary database copy, +never against the current production database. + +Browser parity additionally requires manual/automated screenshots at desktop +and mobile widths for all Console V3 routes, refresh/history behavior, zero +console errors, and asset checksum equality with the locally built frontend. + +World Mini canary smoke additionally requires the sibling route, `index.html`, +status, map, hashed assets, no stale data cache, pan/zoom/reset/layer/LOD and +validation UI, desktop/mobile screenshots, `/api/world/state`, +`/api/world/events`, and `/api/world/stream`. The stream must distinguish +persisted `world-event` frames from heartbeats and reconnect with bounded +backoff. It must not create or invent Runtime events. + +### Source-level integration blocker discovered before OCI provisioning + +An isolated disposable PostgreSQL smoke against this exact source SHA exposed a +real PM Inbox write failure: Admin acknowledge/resolve reaches the repository +but PostgreSQL rejects the upsert because `acknowledged_at` / `resolved_at` is +an ambiguous column reference. Public 401 and PM 403 boundaries still pass. + +This deployment branch does not change product code, so the defect remains a +separate ArchiveOS RC maintenance item. OCI provisioning may be planned, but +`FULL_PARITY_PASS` and production cutover are prohibited until a product-code +fix is approved, tested, and incorporated into a separately approved source +revision. + +## Backup and rollback + +`backup.sh` creates: + +- custom-format PostgreSQL dump; +- Vault archive and per-file SHA-256 manifest; +- aggregate checksums; +- optional upload to the private Object Storage bucket. + +Configure Object Storage lifecycle retention separately; the script does not +delete backups. Schedule Block Volume backups and perform a restore test before +cutover. + +`rollback.sh` changes only immutable image digest references and recreates +services. It does not delete or reset PostgreSQL, Vault, runtime, or backup +data. + +`rollback-world-mini.sh ` is intentionally separate. +It updates only the non-secret Mini image reference and recreates only +`archive-world-mini`. Dynamic Docker DNS in the OCI Nginx route lets the +existing ArchiveOS frontend keep proxying to the replacement; backend, AI, +PostgreSQL, ArchiveOS data, and the World source remain untouched. + +## Cutover gate + +Canary success is not production approval. Report +`READY_FOR_OCI_FULLSTACK_CUTOVER` only after: + +- immutable local/OCIR/Compose digests match; +- DB schema, Flyway history, core row counts, Vault manifest, and official + correlation match; +- RAG uses real OpenAI models and returns evidence; +- all UI, auth, PM Inbox, Live Flow/SSE, Runtime, Knowledge, Batch, RPA, and + Audit gates pass; +- World source provenance, protection flags, Mini route/data/UI, and read-only + World adapter/stream gates pass; +- backup restore is proven; +- the existing deployment and OCI cannot both run scheduler/collector writes. + +Production DNS, write freeze, final dump, active scheduler election, and old +deployment shutdown require separate OS PM approval. diff --git a/deploy/oci/fullstack/docker-compose.oci.yml b/deploy/oci/fullstack/docker-compose.oci.yml new file mode 100644 index 0000000..b1e5bbe --- /dev/null +++ b/deploy/oci/fullstack/docker-compose.oci.yml @@ -0,0 +1,84 @@ +services: + postgres: + image: pgvector/pgvector:pg16 + ports: !override [] + volumes: !override + - /srv/archiveos/postgres:/var/lib/postgresql/data + restart: unless-stopped + + archiveos-ai: + image: ${ARCHIVEOS_AI_IMAGE:?ARCHIVEOS_AI_IMAGE must be an OCIR digest reference} + build: !reset null + ports: !override [] + environment: + SPRING_PROFILES_ACTIVE: rc + ARCHIVEOS_PUBLIC_URL: ${ARCHIVEOS_PUBLIC_URL:?ARCHIVEOS_PUBLIC_URL is required} + ARCHIVEOS_SECURE_COOKIE: "true" + ARCHIVEOS_SCHEDULER_ENABLED: "false" + ARCHIVE_LIVE_FLOW_COLLECTOR_ENABLED: "false" + ARCHIVE_INTEGRATION_SAFE_MODE: "true" + ARCHIVE_INTEGRATION_ALLOW_EXTERNAL_WRITE: "false" + ARCHIVE_INTEGRATION_CALLBACK_ENABLED: "false" + # Only the World PM's verified handoff manifest is mounted. The World + # repository and generated roots are never mounted into ArchiveOS. + ARCHIVE_WORLD_ADAPTER_MODE: live + ARCHIVE_WORLD_MANIFEST_PATH: /world-handoff/archive-world-assets.json + ARCHIVE_WORLD_EVENT_LIMIT: "100" + ARCHIVE_ECOSYSTEM_SERVICES_MARKET_BASE_URL: ${ARCHIVE_ECOSYSTEM_SERVICES_MARKET_BASE_URL:?Market endpoint is required} + ARCHIVE_ECOSYSTEM_SERVICES_NEXUS_BASE_URL: ${ARCHIVE_ECOSYSTEM_SERVICES_NEXUS_BASE_URL:?Nexus endpoint is required} + ARCHIVE_ECOSYSTEM_SERVICES_LOGITICS_BASE_URL: ${ARCHIVE_ECOSYSTEM_SERVICES_LOGISTICS_BASE_URL:?Logistics endpoint is required} + ARCHIVE_ECOSYSTEM_SERVICES_LEDGER_BASE_URL: ${ARCHIVE_ECOSYSTEM_SERVICES_LEDGER_BASE_URL:?Ledger endpoint is required} + volumes: !override + - /srv/archiveos/vault:/vault + - /srv/archiveos/world-handoff/archive-world-assets.json:/world-handoff/archive-world-assets.json:ro + restart: unless-stopped + + archive-world-mini: + image: ${ARCHIVE_WORLD_MINI_IMAGE:?ARCHIVE_WORLD_MINI_IMAGE must be the World PM immutable digest reference} + ports: !override [] + expose: + - "4190" + read_only: true + tmpfs: + - /tmp:rw,noexec,nosuid,size=32m + - /var/run:rw,noexec,nosuid,size=8m + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + restart: unless-stopped + + backend: + image: ${ARCHIVEOS_BACKEND_IMAGE:?ARCHIVEOS_BACKEND_IMAGE must be an OCIR digest reference} + build: !reset null + ports: !override [] + environment: + ARCHIVEOS_AI_BASE_URL: http://archiveos-ai:4100 + ARCHIVEOS_PUBLIC_URL: ${ARCHIVEOS_PUBLIC_URL:?ARCHIVEOS_PUBLIC_URL is required} + CORS_ALLOWED_ORIGINS: ${ARCHIVEOS_PUBLIC_URL:?ARCHIVEOS_PUBLIC_URL is required} + SUPABASE_URL: "" + SUPABASE_SERVICE_ROLE_KEY: "" + MCP_QUEUE_PATH: /runtime/queue + ARCHIVEOS_OBSIDIAN_VAULT_PATH: /vault + volumes: !override + - /srv/archiveos/runtime/queue:/runtime/queue + - /srv/archiveos/vault:/vault:ro + restart: unless-stopped + + frontend: + image: ${ARCHIVEOS_FRONTEND_IMAGE:?ARCHIVEOS_FRONTEND_IMAGE must be an OCIR digest reference} + build: !reset null + ports: !override + - "${ARCHIVEOS_FRONTEND_BIND_ADDRESS:?Private VM address is required}:8080:80" + volumes: !override + - ./deploy/oci/fullstack/nginx/archiveos-oci.conf:/etc/nginx/conf.d/default.conf:ro + depends_on: + backend: + condition: service_healthy + archive-world-mini: + condition: service_started + restart: unless-stopped + +networks: + archive-runtime: + name: ${COMPOSE_PROJECT_NAME:-archiveos-oci}-runtime diff --git a/deploy/oci/fullstack/env/archiveos.oci.env.example b/deploy/oci/fullstack/env/archiveos.oci.env.example new file mode 100644 index 0000000..60570a8 --- /dev/null +++ b/deploy/oci/fullstack/env/archiveos.oci.env.example @@ -0,0 +1,63 @@ +# Copy variable names into OCI Secret Management or an operator-owned, +# root-only runtime source. Never commit a populated copy. +COMPOSE_PROJECT_NAME=archiveos-oci-canary + +# Immutable OCIR references. Values must include @sha256:... +ARCHIVEOS_FRONTEND_IMAGE= +ARCHIVEOS_BACKEND_IMAGE= +ARCHIVEOS_AI_IMAGE= + +# World PM handoff only. Must be the immutable OCIR digest, never a mutable tag. +ARCHIVE_WORLD_MINI_IMAGE= +ARCHIVE_WORLD_MANIFEST_SHA256= +ARCHIVE_WORLD_SOURCE_HEAD= +ARCHIVE_WORLD_EVENT_LIMIT=100 + +# Network and public origin. +ARCHIVEOS_FRONTEND_BIND_ADDRESS= +ARCHIVEOS_PUBLIC_URL=https://archiveos-canary.example.invalid +CORS_ALLOWED_ORIGINS=https://archiveos-canary.example.invalid + +# PostgreSQL. +DB_NAME= +DB_USER= +DB_PASSWORD= + +# OpenAI/Spring AI. +OPENAI_API_KEY= +SPRING_AI_OPENAI_CHAT_OPTIONS_MODEL= +SPRING_AI_OPENAI_EMBEDDING_OPTIONS_MODEL= + +# ArchiveOS sessions and service authentication. +ARCHIVEOS_ADMIN_PASSWORD= +ARCHIVEOS_INTEGRATION_TOKEN= +ARCHIVE_TOKEN_MARKET_TO_OS= +ARCHIVE_TOKEN_NEXUS_TO_OS= +ARCHIVE_TOKEN_LOGISTICS_TO_OS= +ARCHIVE_TOKEN_LEDGER_TO_OS= +ARCHIVE_TOKEN_OS_TO_LEDGER= +ARCHIVE_TOKEN_AUTHENTICATED_READ= +ARCHIVE_TOKEN_ADMIN_OPERATOR= + +# Runtime service endpoints. Do not use localhost, 127.0.0.1, or +# host.docker.internal in OCI. +ARCHIVE_ECOSYSTEM_SERVICES_MARKET_BASE_URL= +ARCHIVE_ECOSYSTEM_SERVICES_NEXUS_BASE_URL= +ARCHIVE_ECOSYSTEM_SERVICES_LOGISTICS_BASE_URL= +ARCHIVE_ECOSYSTEM_SERVICES_LEDGER_BASE_URL= +ARCHIVE_LEDGER_BASE_URL= + +# Optional notifications. +SLACK_WEBHOOK_URL= +SLACK_BOT_TOKEN= +SLACK_CHANNEL= + +# Canary safety contract. These values must remain as shown until cutover +# is separately approved. +ARCHIVEOS_SCHEDULER_ENABLED=false +ARCHIVE_LIVE_FLOW_COLLECTOR_ENABLED=false +ARCHIVE_INTEGRATION_SAFE_MODE=true +ARCHIVE_INTEGRATION_ALLOW_EXTERNAL_WRITE=false +ARCHIVE_INTEGRATION_CALLBACK_ENABLED=false +ARCHIVEOS_SECURE_COOKIE=true +ARCHIVE_WORLD_ADAPTER_MODE=live diff --git a/deploy/oci/fullstack/nginx/archiveos-oci.conf b/deploy/oci/fullstack/nginx/archiveos-oci.conf new file mode 100644 index 0000000..f961212 --- /dev/null +++ b/deploy/oci/fullstack/nginx/archiveos-oci.conf @@ -0,0 +1,84 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + autoindex off; + resolver 127.0.0.11 ipv6=off valid=10s; + resolver_timeout 5s; + set $archive_world_mini_upstream http://archive-world-mini:4190; + + # ArchiveOS keeps its existing backend API contract. The World SSE route is + # separate because it needs unbuffered long-lived HTTP/1.1 proxying. + location = /api/world/stream { + proxy_pass http://backend:4000/api/world/stream; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_buffering off; + proxy_cache off; + proxy_connect_timeout 15s; + proxy_send_timeout 3600s; + proxy_read_timeout 3600s; + } + + location /api/ { + proxy_pass http://backend:4000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_connect_timeout 15s; + proxy_send_timeout 600s; + proxy_read_timeout 600s; + } + + location = /health { + proxy_pass http://backend:4000/health; + proxy_set_header Host $host; + } + + # The two World data documents must never be served stale. proxy_pass + # intentionally has no URI suffix so the full sibling route is preserved. + location = /archive-world-mini/status.json { + proxy_pass $archive_world_mini_upstream$request_uri; + proxy_http_version 1.1; + proxy_set_header Host $host; + add_header Cache-Control "no-store" always; + add_header X-Robots-Tag "noindex, nofollow" always; + } + + location = /archive-world-mini/world-mini-map.json { + proxy_pass $archive_world_mini_upstream$request_uri; + proxy_http_version 1.1; + proxy_set_header Host $host; + add_header Cache-Control "no-store" always; + add_header X-Robots-Tag "noindex, nofollow" always; + } + + location ~ ^/archive-world-mini/.*\.map$ { + return 404; + } + + location ~ ^/archive-world-mini/assets/ { + proxy_pass $archive_world_mini_upstream$request_uri; + proxy_http_version 1.1; + proxy_set_header Host $host; + add_header Cache-Control "public, max-age=31536000, immutable" always; + add_header X-Robots-Tag "noindex, nofollow" always; + } + + location ^~ /archive-world-mini/ { + proxy_pass $archive_world_mini_upstream$request_uri; + proxy_http_version 1.1; + proxy_set_header Host $host; + add_header X-Robots-Tag "noindex, nofollow" always; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/deploy/oci/fullstack/scripts/backup.sh b/deploy/oci/fullstack/scripts/backup.sh new file mode 100755 index 0000000..757b203 --- /dev/null +++ b/deploy/oci/fullstack/scripts/backup.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=${ARCHIVEOS_ROOT:-/opt/archiveos} +ENV_FILE=${ARCHIVEOS_ENV_FILE:-/run/archiveos/archiveos.env} +BACKUP_ROOT=/srv/archiveos/backups +timestamp=$(date -u +%Y%m%dT%H%M%SZ) +target="$BACKUP_ROOT/$timestamp" +mkdir -p "$target" +chmod 0700 "$target" + +set -a +# shellcheck disable=SC1090 +source "$ENV_FILE" +set +a + +compose=(docker compose --env-file "$ENV_FILE" -f "$ROOT/docker-compose.yml" -f "$ROOT/docker-compose.rc.yml" -f "$ROOT/deploy/oci/fullstack/docker-compose.oci.yml") + +"${compose[@]}" exec -T postgres pg_dump -Fc --no-owner --no-acl -U "$DB_USER" -d "$DB_NAME" >"$target/archiveos.dump" +tar --xattrs --acls -C /srv/archiveos -czf "$target/vault.tar.gz" vault +find /srv/archiveos/vault -type f -printf '%P\0' | sort -z | xargs -0 -r sha256sum --tag >"$target/vault.sha256" +sha256sum "$target/archiveos.dump" "$target/vault.tar.gz" >"$target/backup.sha256" + +if [[ -n ${ARCHIVEOS_BACKUP_BUCKET:-} ]]; then + for file in archiveos.dump vault.tar.gz vault.sha256 backup.sha256; do + oci --auth instance_principal os object put \ + --bucket-name "$ARCHIVEOS_BACKUP_BUCKET" \ + --name "archiveos/$timestamp/$file" \ + --file "$target/$file" \ + --force >/dev/null + done +fi + +echo "BACKUP_PASS path=$target" diff --git a/deploy/oci/fullstack/scripts/build-images.ps1 b/deploy/oci/fullstack/scripts/build-images.ps1 new file mode 100644 index 0000000..65db80f --- /dev/null +++ b/deploy/oci/fullstack/scripts/build-images.ps1 @@ -0,0 +1,92 @@ +param( + [string]$ImagePrefix = "archiveos", + [string]$Revision = "c2324df9a935e897fbe8be94068f862c4ac3956f" +) + +$ErrorActionPreference = "Stop" +$Root = (Resolve-Path "$PSScriptRoot\..\..\..\..").Path + +function Assert-LastExit([string]$Step) { + if ($LASTEXITCODE -ne 0) { throw "$Step failed with exit code $LASTEXITCODE." } +} + +function Invoke-RepositoryGit { + param([Parameter(ValueFromRemainingArguments = $true)][string[]]$Arguments) + if (Get-Command git -ErrorAction SilentlyContinue) { + & git @Arguments + return + } + if (-not (Get-Command wsl -ErrorAction SilentlyContinue)) { + throw "Git is unavailable in Windows and WSL." + } + if ($Root -notmatch "^([A-Za-z]):\\(.*)$") { + throw "Cannot translate repository path to WSL." + } + $wslRoot = "/mnt/$($Matches[1].ToLowerInvariant())/$($Matches[2].Replace('\', '/'))" + & wsl -d Ubuntu -- git -C $wslRoot @Arguments +} + +Push-Location $Root +try { + $head = (Invoke-RepositoryGit rev-parse HEAD).Trim() + Assert-LastExit "git rev-parse" + if ($head -ne $Revision) { throw "Source revision mismatch." } + + $forbidden = Invoke-RepositoryGit diff --name-only $Revision -- src backend archiveos-ai + Assert-LastExit "product source diff check" + if ($forbidden) { throw "Product source has local changes; image build refused." } + + $labels = @( + "--label", "org.opencontainers.image.source=CSJ-PJT/ArchiveOS", + "--label", "org.opencontainers.image.revision=$Revision", + "--label", "org.opencontainers.image.version=oci-fullstack-v1" + ) + $images = [ordered]@{ + frontend = "$ImagePrefix/frontend`:$Revision" + backend = "$ImagePrefix/backend`:$Revision" + ai = "$ImagePrefix/ai`:$Revision" + } + + docker build @labels -t $images.frontend . + Assert-LastExit "frontend image build" + docker build @labels -t $images.backend ./backend + Assert-LastExit "backend image build" + docker build @labels -t $images.ai ./archiveos-ai + Assert-LastExit "AI image build" + + $container = docker create $images.frontend + Assert-LastExit "frontend image inspection container" + $temp = Join-Path ([System.IO.Path]::GetTempPath()) "archiveos-frontend-$([guid]::NewGuid())" + New-Item -ItemType Directory -Path $temp | Out-Null + try { + docker cp "${container}:/usr/share/nginx/html/." $temp + Assert-LastExit "frontend asset extraction" + $assetManifest = Get-ChildItem $temp -Recurse -File | Sort-Object FullName | ForEach-Object { + $relative = $_.FullName.Substring($temp.Length).TrimStart("\") + "$((Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLowerInvariant()) $relative" + } + $sha256 = [System.Security.Cryptography.SHA256]::Create() + try { + $hash = $sha256.ComputeHash([Text.Encoding]::UTF8.GetBytes(($assetManifest -join "`n"))) + $assetChecksum = -join ($hash | ForEach-Object { $_.ToString("x2") }) + } finally { + $sha256.Dispose() + } + } finally { + docker rm $container | Out-Null + Remove-Item -LiteralPath $temp -Recurse -Force + } + + foreach ($entry in $images.GetEnumerator()) { + $id = (docker image inspect --format "{{.Id}}" $entry.Value).Trim() + [pscustomobject]@{ + Component = $entry.Key + Image = $entry.Value + LocalDigest = $id + Revision = $Revision + } + } + "FRONTEND_ASSET_CHECKSUM=$assetChecksum" +} finally { + Pop-Location +} diff --git a/deploy/oci/fullstack/scripts/deploy-canary.sh b/deploy/oci/fullstack/scripts/deploy-canary.sh new file mode 100755 index 0000000..121d37d --- /dev/null +++ b/deploy/oci/fullstack/scripts/deploy-canary.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=${ARCHIVEOS_ROOT:-/opt/archiveos} +ENV_FILE=${ARCHIVEOS_ENV_FILE:-/run/archiveos/archiveos.env} +cd "$ROOT" + +"$ROOT/deploy/oci/fullstack/scripts/preflight.sh" + +docker compose --env-file "$ENV_FILE" \ + -f docker-compose.yml \ + -f docker-compose.rc.yml \ + -f deploy/oci/fullstack/docker-compose.oci.yml \ + pull + +docker compose --env-file "$ENV_FILE" \ + -f docker-compose.yml \ + -f docker-compose.rc.yml \ + -f deploy/oci/fullstack/docker-compose.oci.yml \ + up -d --wait + +docker compose --env-file "$ENV_FILE" \ + -f docker-compose.yml \ + -f docker-compose.rc.yml \ + -f deploy/oci/fullstack/docker-compose.oci.yml \ + exec -T frontend nginx -t + +for route in /archive-world-mini/ /archive-world-mini/status.json /archive-world-mini/world-mini-map.json; do + docker compose --env-file "$ENV_FILE" \ + -f docker-compose.yml \ + -f docker-compose.rc.yml \ + -f deploy/oci/fullstack/docker-compose.oci.yml \ + exec -T frontend wget -q -O /dev/null "http://127.0.0.1${route}" +done + +docker compose --env-file "$ENV_FILE" \ + -f docker-compose.yml \ + -f docker-compose.rc.yml \ + -f deploy/oci/fullstack/docker-compose.oci.yml \ + ps + +echo "CANARY_DEPLOY_PASS" diff --git a/deploy/oci/fullstack/scripts/fetch-secrets.sh b/deploy/oci/fullstack/scripts/fetch-secrets.sh new file mode 100755 index 0000000..528273a --- /dev/null +++ b/deploy/oci/fullstack/scripts/fetch-secrets.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +set -euo pipefail + +OUTPUT=/run/archiveos/archiveos.env +MAPPING=/etc/archiveos/secret-ocids.env +CONFIG=/etc/archiveos/archiveos.oci.conf + +if [[ ${1:-} == "--delete" ]]; then + rm -f "$OUTPUT" + exit 0 +fi + +[[ ${EUID} -eq 0 ]] || { echo "Run as root." >&2; exit 1; } +command -v oci >/dev/null 2>&1 || { echo "OCI CLI is required." >&2; exit 1; } +[[ -f "$MAPPING" ]] || { echo "Secret OCID mapping is missing." >&2; exit 1; } +[[ -f "$CONFIG" ]] || { echo "Non-secret OCI configuration is missing." >&2; exit 1; } + +install -d -m 0700 /run/archiveos +tmp=$(mktemp /run/archiveos/archiveos.env.XXXXXX) +trap 'rm -f "$tmp"' EXIT +chmod 0600 "$tmp" + +allowed_config='^(COMPOSE_PROJECT_NAME|ARCHIVEOS_FRONTEND_IMAGE|ARCHIVEOS_BACKEND_IMAGE|ARCHIVEOS_AI_IMAGE|ARCHIVE_WORLD_MINI_IMAGE|ARCHIVE_WORLD_MANIFEST_SHA256|ARCHIVE_WORLD_SOURCE_HEAD|ARCHIVE_WORLD_EVENT_LIMIT|ARCHIVEOS_FRONTEND_BIND_ADDRESS|ARCHIVEOS_PUBLIC_URL|CORS_ALLOWED_ORIGINS|SPRING_AI_OPENAI_CHAT_OPTIONS_MODEL|SPRING_AI_OPENAI_EMBEDDING_OPTIONS_MODEL|ARCHIVE_ECOSYSTEM_SERVICES_[A-Z_]+_BASE_URL|ARCHIVE_LEDGER_BASE_URL|SLACK_CHANNEL|ARCHIVEOS_SCHEDULER_ENABLED|ARCHIVE_LIVE_FLOW_COLLECTOR_ENABLED|ARCHIVE_INTEGRATION_SAFE_MODE|ARCHIVE_INTEGRATION_ALLOW_EXTERNAL_WRITE|ARCHIVE_INTEGRATION_CALLBACK_ENABLED|ARCHIVEOS_SECURE_COOKIE|ARCHIVE_WORLD_ADAPTER_MODE)=' +grep -E "$allowed_config" "$CONFIG" >>"$tmp" + +while IFS='=' read -r name secret_id; do + [[ -z "$name" || "$name" == \#* ]] && continue + [[ "$name" =~ ^[A-Z][A-Z0-9_]+$ ]] || { echo "Invalid secret variable name." >&2; exit 1; } + [[ "$secret_id" == ocid1.vaultsecret.* ]] || { echo "Invalid secret OCID mapping for $name." >&2; exit 1; } + value=$( + oci --auth instance_principal secrets secret-bundle get \ + --secret-id "$secret_id" \ + --stage CURRENT \ + --query 'data."secret-bundle-content".content' \ + --raw-output | base64 --decode + ) + [[ "$value" != *$'\n'* && "$value" != *$'\r'* ]] || { echo "Multiline secret is not supported for $name." >&2; exit 1; } + escaped=${value//\'/\\\'} + printf "%s='%s'\n" "$name" "$escaped" >>"$tmp" + unset value escaped +done <"$MAPPING" + +for required in DB_NAME DB_USER DB_PASSWORD OPENAI_API_KEY ARCHIVEOS_ADMIN_PASSWORD \ + ARCHIVE_TOKEN_MARKET_TO_OS ARCHIVE_TOKEN_NEXUS_TO_OS ARCHIVE_TOKEN_LOGISTICS_TO_OS \ + ARCHIVE_TOKEN_LEDGER_TO_OS ARCHIVE_TOKEN_OS_TO_LEDGER ARCHIVE_TOKEN_AUTHENTICATED_READ \ + ARCHIVE_TOKEN_ADMIN_OPERATOR ARCHIVEOS_INTEGRATION_TOKEN; do + grep -q "^${required}=" "$tmp" || { echo "Missing required secret mapping: $required" >&2; exit 1; } +done + +chown root:root "$tmp" +chmod 0600 "$tmp" +mv -f "$tmp" "$OUTPUT" +trap - EXIT +echo "SECRET_LOAD_PASS" diff --git a/deploy/oci/fullstack/scripts/install-runtime.sh b/deploy/oci/fullstack/scripts/install-runtime.sh new file mode 100755 index 0000000..45c4aac --- /dev/null +++ b/deploy/oci/fullstack/scripts/install-runtime.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +[[ ${EUID} -eq 0 ]] || { echo "Run as root." >&2; exit 1; } + +if command -v dnf >/dev/null 2>&1; then + dnf install -y docker-engine docker-cli jq oci-cli xfsprogs +elif command -v apt-get >/dev/null 2>&1; then + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y docker.io docker-compose-v2 jq xfsprogs + command -v oci >/dev/null 2>&1 || { + echo "OCI CLI is not available from this image's configured repositories." >&2 + echo "Install OCI CLI from Oracle's signed distribution before deployment." >&2 + exit 1 + } +else + echo "Unsupported operating system package manager." >&2 + exit 1 +fi + +systemctl enable --now docker +docker version >/dev/null +docker compose version >/dev/null +oci --version +echo "RUNTIME_INSTALL_PASS" diff --git a/deploy/oci/fullstack/scripts/migrate-data.ps1 b/deploy/oci/fullstack/scripts/migrate-data.ps1 new file mode 100644 index 0000000..d190e35 --- /dev/null +++ b/deploy/oci/fullstack/scripts/migrate-data.ps1 @@ -0,0 +1,85 @@ +param( + [ValidateSet("Inventory", "Dump", "Restore", "Compare")][string]$Mode = "Inventory", + [string]$SourceProjectRoot, + [string]$SourceEnvFile, + [string]$ArtifactRoot = ".\artifacts\oci-migration", + [string]$TargetSshHost, + [string]$TargetRoot = "/opt/archiveos", + [string]$TargetInventoryPath, + [switch]$ConfirmTargetRestore +) + +$ErrorActionPreference = "Stop" +if ([string]::IsNullOrWhiteSpace($SourceProjectRoot)) { + $SourceProjectRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..\..\..")).Path +} +if ([string]::IsNullOrWhiteSpace($SourceEnvFile)) { + $SourceEnvFile = Join-Path $SourceProjectRoot ".env.rc" +} +$CoreTables = @( + "runtime_timeline", "audit_logs", "ecosystem_flow_event", "managed_system_pm_inbox_state", + "obsidian_documents", "obsidian_chunks", "batch_job_instance", "batch_job_execution", + "rpa_tasks", "rpa_decisions", "external_approval_requests" +) + +function ComposePrefix { + @( + "compose", "--env-file", $SourceEnvFile, + "-f", (Join-Path $SourceProjectRoot "docker-compose.yml"), + "-f", (Join-Path $SourceProjectRoot "docker-compose.rc.yml") + ) +} + +function Invoke-SourcePsql([string]$Sql) { + $args = ComposePrefix + & docker @args exec -T -e "ARCHIVEOS_READ_SQL=$Sql" postgres sh -lc 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Atc "$ARCHIVEOS_READ_SQL"' + if ($LASTEXITCODE -ne 0) { throw "Read-only source query failed." } +} + +New-Item -ItemType Directory -Force -Path $ArtifactRoot | Out-Null +$inventoryPath = Join-Path $ArtifactRoot "source-inventory.tsv" +$dumpPath = Join-Path $ArtifactRoot "archiveos.dump" + +if ($Mode -in @("Inventory", "Dump", "Compare")) { + $rows = foreach ($table in $CoreTables) { + $sql = "select case when to_regclass('public.$table') is null then 'MISSING' else (select count(*)::text from public.$table) end;" + [pscustomobject]@{ Table = $table; Rows = (Invoke-SourcePsql $sql | Select-Object -First 1) } + } + $rows | Export-Csv -Delimiter "`t" -NoTypeInformation -Path $inventoryPath + $rows +} + +if ($Mode -eq "Dump") { + $args = ComposePrefix + & docker @args exec -T postgres sh -lc 'pg_dump -Fc --no-owner --no-acl -U "$POSTGRES_USER" -d "$POSTGRES_DB" -f /tmp/archiveos-oci.dump' + if ($LASTEXITCODE -ne 0) { throw "Source pg_dump failed." } + $containerId = (& docker @args ps -q postgres).Trim() + if (-not $containerId) { throw "Source PostgreSQL container was not found." } + docker cp "${containerId}:/tmp/archiveos-oci.dump" $dumpPath + if ($LASTEXITCODE -ne 0) { throw "Dump extraction failed." } + & docker @args exec -T postgres rm -f /tmp/archiveos-oci.dump + Get-FileHash $dumpPath -Algorithm SHA256 +} + +if ($Mode -eq "Restore") { + if (-not $ConfirmTargetRestore) { throw "Restore requires -ConfirmTargetRestore." } + if (-not $TargetSshHost) { throw "TargetSshHost is required." } + if (-not (Test-Path $dumpPath)) { throw "Migration dump is missing." } + scp $dumpPath "${TargetSshHost}:/tmp/archiveos-oci.dump" + if ($LASTEXITCODE -ne 0) { throw "Encrypted dump transfer failed." } + ssh $TargetSshHost "cd '$TargetRoot' && cid=`$(docker compose -f docker-compose.yml -f docker-compose.rc.yml -f deploy/oci/fullstack/docker-compose.oci.yml ps -q postgres) && docker cp /tmp/archiveos-oci.dump \"`$cid:/tmp/archiveos-oci.dump\" && docker compose -f docker-compose.yml -f docker-compose.rc.yml -f deploy/oci/fullstack/docker-compose.oci.yml exec -T postgres sh -lc 'pg_restore --no-owner --no-acl --exit-on-error -U \"`$POSTGRES_USER\" -d \"`$POSTGRES_DB\" /tmp/archiveos-oci.dump' && rm -f /tmp/archiveos-oci.dump" + if ($LASTEXITCODE -ne 0) { throw "Target restore failed." } +} + +if ($Mode -eq "Compare") { + if (-not $TargetInventoryPath -or -not (Test-Path $TargetInventoryPath)) { + throw "Compare requires a target inventory TSV produced by running Inventory against the canary." + } + $sourceRows = Import-Csv -Delimiter "`t" $inventoryPath + $targetRows = Import-Csv -Delimiter "`t" $TargetInventoryPath + foreach ($table in $CoreTables) { + $source = ($sourceRows | Where-Object Table -eq $table).Rows + $target = ($targetRows | Where-Object Table -eq $table).Rows + [pscustomobject]@{ Table = $table; Source = $source; Target = $target; Match = $source -eq $target } + } +} diff --git a/deploy/oci/fullstack/scripts/mount-storage.sh b/deploy/oci/fullstack/scripts/mount-storage.sh new file mode 100755 index 0000000..ca1ed06 --- /dev/null +++ b/deploy/oci/fullstack/scripts/mount-storage.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { echo "Usage: $0 [--format]" >&2; exit 2; } +[[ ${EUID} -eq 0 ]] || { echo "Run as root." >&2; exit 1; } +[[ $# -ge 1 ]] || usage + +device=$1 +allow_format=${2:-} +[[ -b "$device" ]] || { echo "Not a block device: $device" >&2; exit 1; } + +filesystem=$(blkid -s TYPE -o value "$device" 2>/dev/null || true) +if [[ -z "$filesystem" ]]; then + [[ "$allow_format" == "--format" ]] || { + echo "Device is unformatted. Re-run with --format only after confirming the OCI Block Volume." >&2 + exit 1 + } + mkfs.xfs -f "$device" + filesystem=xfs +fi +[[ "$filesystem" == "xfs" || "$filesystem" == "ext4" ]] || { + echo "Unsupported filesystem: $filesystem" >&2 + exit 1 +} + +mkdir -p /srv/archiveos +uuid=$(blkid -s UUID -o value "$device") +grep -q "UUID=$uuid " /etc/fstab || printf 'UUID=%s /srv/archiveos %s defaults,nofail 0 2\n' "$uuid" "$filesystem" >>/etc/fstab +mountpoint -q /srv/archiveos || mount /srv/archiveos + +install -d -m 0700 /srv/archiveos/postgres +install -d -m 0750 /srv/archiveos/vault /srv/archiveos/backups /srv/archiveos/runtime /srv/archiveos/runtime/queue +echo "STORAGE_MOUNT_PASS" diff --git a/deploy/oci/fullstack/scripts/preflight.sh b/deploy/oci/fullstack/scripts/preflight.sh new file mode 100755 index 0000000..aaac13b --- /dev/null +++ b/deploy/oci/fullstack/scripts/preflight.sh @@ -0,0 +1,107 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=${ARCHIVEOS_ROOT:-/opt/archiveos} +ENV_FILE=${ARCHIVEOS_ENV_FILE:-/run/archiveos/archiveos.env} +COMPOSE_FILES=(-f "$ROOT/docker-compose.yml" -f "$ROOT/docker-compose.rc.yml" -f "$ROOT/deploy/oci/fullstack/docker-compose.oci.yml") + +fail() { printf 'PREFLIGHT_FAIL: %s\n' "$1" >&2; exit 1; } +require() { command -v "$1" >/dev/null 2>&1 || fail "missing command: $1"; } + +[[ ${EUID} -eq 0 ]] || fail "run as root" +require docker +require jq +require sha256sum +docker compose version >/dev/null + +[[ -f "$ENV_FILE" ]] || fail "runtime env file missing" +[[ "$(stat -c '%a' "$ENV_FILE")" == "600" ]] || fail "runtime env mode must be 0600" +[[ "$(stat -c '%U:%G' "$ENV_FILE")" == "root:root" ]] || fail "runtime env owner must be root:root" + +for path in /srv/archiveos/postgres /srv/archiveos/vault /srv/archiveos/backups /srv/archiveos/runtime/queue /srv/archiveos/world-handoff; do + [[ -d "$path" ]] || fail "missing persistent path: $path" +done +mountpoint -q /srv/archiveos || fail "/srv/archiveos is not a mounted persistent filesystem" + +set -a +# shellcheck disable=SC1090 +source "$ENV_FILE" +set +a + +for name in ARCHIVEOS_FRONTEND_IMAGE ARCHIVEOS_BACKEND_IMAGE ARCHIVEOS_AI_IMAGE ARCHIVE_WORLD_MINI_IMAGE; do + value=${!name:-} + [[ "$value" =~ @sha256:[0-9a-f]{64}$ ]] || fail "$name must be pinned by digest" +done + +[[ ${ARCHIVEOS_PUBLIC_URL:-} == https://* ]] || fail "ARCHIVEOS_PUBLIC_URL must use HTTPS" +[[ ${ARCHIVEOS_SECURE_COOKIE:-} == "true" ]] || fail "secure cookie must be enabled" +[[ ${ARCHIVEOS_SCHEDULER_ENABLED:-} == "false" ]] || fail "scheduler must remain disabled for canary" +[[ ${ARCHIVE_LIVE_FLOW_COLLECTOR_ENABLED:-} == "false" ]] || fail "collector must remain disabled for canary" +[[ ${ARCHIVE_INTEGRATION_ALLOW_EXTERNAL_WRITE:-} == "false" ]] || fail "external writes must remain disabled for canary" +[[ ${ARCHIVE_INTEGRATION_CALLBACK_ENABLED:-} == "false" ]] || fail "callbacks must remain disabled for canary" +[[ ${ARCHIVE_WORLD_ADAPTER_MODE:-} == "live" ]] || fail "World adapter must be live for the World Mini canary" +[[ ${ARCHIVE_WORLD_EVENT_LIMIT:-} == "100" ]] || fail "World event limit must remain 100" +[[ ${ARCHIVE_WORLD_SOURCE_HEAD:-} =~ ^[0-9a-f]{40}$ ]] || fail "ARCHIVE_WORLD_SOURCE_HEAD must be a full immutable Git SHA" +[[ ${ARCHIVE_WORLD_MANIFEST_SHA256:-} =~ ^[0-9a-f]{64}$ ]] || fail "ARCHIVE_WORLD_MANIFEST_SHA256 must be SHA-256" + +world_manifest=/srv/archiveos/world-handoff/archive-world-assets.json +world_provenance=/srv/archiveos/world-handoff/provenance.json +[[ -f "$world_manifest" ]] || fail "World PM handoff manifest is missing" +[[ -f "$world_provenance" ]] || fail "World PM provenance is missing" +actual_manifest_sha=$(sha256sum "$world_manifest" | awk '{print $1}') +[[ "$actual_manifest_sha" == "$ARCHIVE_WORLD_MANIFEST_SHA256" ]] || fail "World manifest checksum mismatch" +jq -e \ + --arg source_head "$ARCHIVE_WORLD_SOURCE_HEAD" \ + --arg manifest_sha "$ARCHIVE_WORLD_MANIFEST_SHA256" ' + .schemaVersion == "1.0.0" + and .sourceProject == "Archive-World" + and .sourceHead == $source_head + and .manifestSha256 == $manifest_sha + and .actualBuildEvidence == true + and .prototypeTarget == false + and .canonical == false + and .v3Applied == false + and .runtimeMutation == false + and .mainMerge == false + ' "$world_provenance" >/dev/null || fail "World PM provenance/protection flags failed" + +for name in \ + ARCHIVE_ECOSYSTEM_SERVICES_MARKET_BASE_URL \ + ARCHIVE_ECOSYSTEM_SERVICES_NEXUS_BASE_URL \ + ARCHIVE_ECOSYSTEM_SERVICES_LOGISTICS_BASE_URL \ + ARCHIVE_ECOSYSTEM_SERVICES_LEDGER_BASE_URL; do + value=${!name:-} + [[ -n "$value" ]] || fail "$name is required" + [[ "$value" != *localhost* && "$value" != *127.0.0.1* && "$value" != *host.docker.internal* ]] \ + || fail "$name uses a forbidden local endpoint" +done + +config=$(mktemp) +trap 'rm -f "$config"' EXIT +docker compose --env-file "$ENV_FILE" "${COMPOSE_FILES[@]}" config --format json >"$config" + +jq -e ' + [.services.postgres.ports // [], .services.backend.ports // [], .services["archiveos-ai"].ports // []] + | flatten | length == 0 +' "$config" >/dev/null || fail "postgres/backend/AI must not publish host ports" + +jq -e ' + (.services.frontend.ports | length) == 1 + and (.services.frontend.ports[0].published == "8080") +' "$config" >/dev/null || fail "frontend must be the only published service on port 8080" + +jq -e ' + (.services["archive-world-mini"].ports // [] | length) == 0 + and (.services["archive-world-mini"].expose | index("4190") != null) + and .services["archive-world-mini"].read_only == true + and (.services["archive-world-mini"].volumes // [] | length) == 0 +' "$config" >/dev/null || fail "World Mini private/read-only container contract failed" + +jq -e ' + [(.services["archiveos-ai"].volumes // [])[].source] + | any(. == "/srv/archiveos/vault") + and any(. == "/srv/archiveos/world-handoff/archive-world-assets.json") + and all(. != null; contains("Archive-World") | not) +' "$config" >/dev/null || fail "AI vault/World mount contract failed" + +printf 'PREFLIGHT_PASS\n' diff --git a/deploy/oci/fullstack/scripts/push-images.ps1 b/deploy/oci/fullstack/scripts/push-images.ps1 new file mode 100644 index 0000000..eacffb6 --- /dev/null +++ b/deploy/oci/fullstack/scripts/push-images.ps1 @@ -0,0 +1,36 @@ +param( + [Parameter(Mandatory = $true)][string]$Region, + [Parameter(Mandatory = $true)][string]$Namespace, + [string]$RepositoryPrefix = "archiveos", + [string]$LocalPrefix = "archiveos", + [string]$Revision = "c2324df9a935e897fbe8be94068f862c4ac3956f" +) + +$ErrorActionPreference = "Stop" +$registry = "$Region.ocir.io" +$components = @("frontend", "backend", "ai") + +foreach ($component in $components) { + $local = "$LocalPrefix/$component`:$Revision" + $remote = "$registry/$Namespace/$RepositoryPrefix/$component`:$Revision" + docker image inspect $local *> $null + if ($LASTEXITCODE -ne 0) { throw "Local immutable image is missing: $component" } + + docker tag $local $remote + if ($LASTEXITCODE -ne 0) { throw "Tag failed: $component" } + docker push $remote + if ($LASTEXITCODE -ne 0) { throw "Push failed: $component" } + + $inspection = docker buildx imagetools inspect $remote --format "{{json .Manifest}}" + if ($LASTEXITCODE -ne 0) { throw "Remote digest inspection failed: $component" } + $manifest = $inspection | ConvertFrom-Json + if (-not $manifest.digest -or $manifest.digest -notmatch "^sha256:[0-9a-f]{64}$") { + throw "Remote digest is invalid: $component" + } + [pscustomobject]@{ + Component = $component + ImmutableTag = $remote + DigestReference = "$remote@$($manifest.digest)" + Revision = $Revision + } +} diff --git a/deploy/oci/fullstack/scripts/rollback-world-mini.sh b/deploy/oci/fullstack/scripts/rollback-world-mini.sh new file mode 100755 index 0000000..338316d --- /dev/null +++ b/deploy/oci/fullstack/scripts/rollback-world-mini.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=${ARCHIVEOS_ROOT:-/opt/archiveos} +ENV_FILE=${ARCHIVEOS_ENV_FILE:-/run/archiveos/archiveos.env} +CONFIG=/etc/archiveos/archiveos.oci.conf +COMPOSE_FILES=(-f "$ROOT/docker-compose.yml" -f "$ROOT/docker-compose.rc.yml" -f "$ROOT/deploy/oci/fullstack/docker-compose.oci.yml") + +fail() { printf 'WORLD_MINI_ROLLBACK_FAIL: %s\n' "$1" >&2; exit 1; } +[[ ${EUID} -eq 0 ]] || fail "run as root" +[[ $# -eq 1 ]] || fail "usage: rollback-world-mini.sh " +previous_image=$1 +[[ "$previous_image" =~ ^.+@sha256:[0-9a-f]{64}$ ]] || fail "previous image must be an immutable digest reference" +[[ -f "$CONFIG" && -f "$ENV_FILE" ]] || fail "OCI configuration or runtime environment is missing" + +tmp=$(mktemp "${CONFIG}.XXXXXX") +trap 'rm -f "$tmp"' EXIT +if grep -q '^ARCHIVE_WORLD_MINI_IMAGE=' "$CONFIG"; then + sed "s|^ARCHIVE_WORLD_MINI_IMAGE=.*|ARCHIVE_WORLD_MINI_IMAGE=${previous_image}|" "$CONFIG" >"$tmp" +else + cat "$CONFIG" >"$tmp" + printf '\nARCHIVE_WORLD_MINI_IMAGE=%s\n' "$previous_image" >>"$tmp" +fi +install -o root -g root -m 0600 "$tmp" "$CONFIG" + +"$ROOT/deploy/oci/fullstack/scripts/fetch-secrets.sh" +cd "$ROOT" +docker pull "$previous_image" +docker compose --env-file "$ENV_FILE" "${COMPOSE_FILES[@]}" up -d --no-deps --force-recreate archive-world-mini +docker compose --env-file "$ENV_FILE" "${COMPOSE_FILES[@]}" ps archive-world-mini + +printf 'WORLD_MINI_ROLLBACK_PASS\n' diff --git a/deploy/oci/fullstack/scripts/rollback.sh b/deploy/oci/fullstack/scripts/rollback.sh new file mode 100755 index 0000000..e6dd79d --- /dev/null +++ b/deploy/oci/fullstack/scripts/rollback.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=${ARCHIVEOS_ROOT:-/opt/archiveos} +ENV_FILE=${ARCHIVEOS_ENV_FILE:-/run/archiveos/archiveos.env} +PREVIOUS=${ARCHIVEOS_PREVIOUS_IMAGES_FILE:-/srv/archiveos/runtime/previous-image-digests.env} + +[[ -f "$PREVIOUS" ]] || { echo "Previous image digest file is missing." >&2; exit 1; } +[[ "$(stat -c '%a' "$PREVIOUS")" == "600" ]] || { echo "Previous image digest file must be mode 0600." >&2; exit 1; } + +for name in ARCHIVEOS_FRONTEND_IMAGE ARCHIVEOS_BACKEND_IMAGE ARCHIVEOS_AI_IMAGE; do + value=$(grep -E "^${name}=" "$PREVIOUS" | head -n1 | cut -d= -f2-) + [[ "$value" =~ @sha256:[0-9a-f]{64}$ ]] || { echo "Invalid previous digest for $name." >&2; exit 1; } + sed -i "s|^${name}=.*|${name}=${value}|" "$ENV_FILE" +done + +cd "$ROOT" +docker compose --env-file "$ENV_FILE" \ + -f docker-compose.yml \ + -f docker-compose.rc.yml \ + -f deploy/oci/fullstack/docker-compose.oci.yml \ + up -d --pull always --wait + +echo "ROLLBACK_PASS" diff --git a/deploy/oci/fullstack/scripts/verify-fullstack.ps1 b/deploy/oci/fullstack/scripts/verify-fullstack.ps1 new file mode 100644 index 0000000..af0b4c2 --- /dev/null +++ b/deploy/oci/fullstack/scripts/verify-fullstack.ps1 @@ -0,0 +1,279 @@ +param( + [Parameter(Mandatory = $true)][uri]$BaseUrl, + [string]$AdminPasswordEnvironmentVariable = "ARCHIVEOS_ADMIN_PASSWORD", + [string]$ReadTokenEnvironmentVariable = "ARCHIVE_TOKEN_AUTHENTICATED_READ", + [string]$CorrelationId = "CORR-a98d539e-8497-4d0a-9a41-49690c2bf0b0" +) + +$ErrorActionPreference = "Stop" +$origin = $BaseUrl.AbsoluteUri.TrimEnd("/") +if ($BaseUrl.Scheme -ne "https") { throw "OCI verification requires HTTPS." } + +function Request { + param([string]$Path, [string]$Method = "GET", $Body, $Session, $Headers) + $params = @{ Uri = "$origin$Path"; Method = $Method; UseBasicParsing = $true } + if ($Body) { $params.Body = ($Body | ConvertTo-Json -Depth 10); $params.ContentType = "application/json" } + if ($Session) { $params.WebSession = $Session } + if ($Headers) { $params.Headers = $Headers } + Invoke-WebRequest @params +} + +function ExpectStatus { + param([string]$Path, [string]$Method, [int]$Expected, $Body, $Session, $Headers) + try { + $response = Request -Path $Path -Method $Method -Body $Body -Session $Session -Headers $Headers + $actual = [int]$response.StatusCode + } catch { + $actual = [int]$_.Exception.Response.StatusCode + } + if ($actual -ne $Expected) { throw "$Method $Path expected $Expected but received $actual." } + [pscustomobject]@{ Path = $Path; Status = $actual } +} + +function Json { + param([string]$Path, [string]$Method = "GET", $Body, $Session, $Headers) + (Request -Path $Path -Method $Method -Body $Body -Session $Session -Headers $Headers).Content | + ConvertFrom-Json +} + +function Test-Sse { + param([string]$Path, [hashtable]$Headers, $Session, [string]$ExpectedEvent) + $client = [System.Net.Http.HttpClient]::new() + $client.Timeout = [TimeSpan]::FromSeconds(10) + try { + foreach ($entry in $Headers.GetEnumerator()) { + [void]$client.DefaultRequestHeaders.TryAddWithoutValidation($entry.Key, [string]$entry.Value) + } + if ($Session) { + $cookies = $Session.Cookies.GetCookieHeader([uri]$origin) + if ($cookies) { [void]$client.DefaultRequestHeaders.TryAddWithoutValidation("Cookie", $cookies) } + } + $client.DefaultRequestHeaders.Accept.ParseAdd("text/event-stream") + $response = $client.GetAsync( + "$origin$Path", + [System.Net.Http.HttpCompletionOption]::ResponseHeadersRead + ).GetAwaiter().GetResult() + if (-not $response.IsSuccessStatusCode) { + throw "SSE endpoint returned HTTP $([int]$response.StatusCode)." + } + if ($response.Content.Headers.ContentType.MediaType -ne "text/event-stream") { + throw "SSE endpoint did not return text/event-stream." + } + $stream = $response.Content.ReadAsStreamAsync().GetAwaiter().GetResult() + $reader = [System.IO.StreamReader]::new($stream) + $deadline = (Get-Date).AddSeconds(5) + $eventName = $null + while ((Get-Date) -lt $deadline) { + $readTask = $reader.ReadLineAsync() + $remaining = [Math]::Max(1, [int](($deadline - (Get-Date)).TotalMilliseconds)) + if (-not $readTask.Wait($remaining)) { break } + $line = $readTask.Result + if ($line -match '^event:\s*(.+)$') { $eventName = $Matches[1].Trim() } + if ([string]::IsNullOrWhiteSpace($line) -and $eventName) { break } + } + if ([string]::IsNullOrWhiteSpace($eventName)) { throw "SSE stream returned no event frame." } + if ($ExpectedEvent -and $eventName -ne $ExpectedEvent) { throw "SSE expected $ExpectedEvent but received $eventName." } + return $eventName + } finally { + $client.Dispose() + } +} + +ExpectStatus -Path "/" -Method GET -Expected 200 +$miniRoot = Request -Path "/archive-world-mini/" +if ($miniRoot.StatusCode -ne 200) { throw "Mini World root route failed." } +ExpectStatus -Path "/archive-world-mini/index.html" -Method GET -Expected 200 +$miniStatusResponse = Request -Path "/archive-world-mini/status.json" +$miniMapResponse = Request -Path "/archive-world-mini/world-mini-map.json" +foreach ($response in @($miniStatusResponse, $miniMapResponse)) { + if ($response.StatusCode -ne 200 -or $response.Headers['Cache-Control'] -notmatch 'no-store|no-cache') { + throw "Mini World live data cache contract failed." + } + if ($response.Headers['X-Robots-Tag'] -notmatch 'noindex') { throw "Mini World canary indexing policy failed." } +} +$miniStatus = $miniStatusResponse.Content | ConvertFrom-Json +$miniMap = $miniMapResponse.Content | ConvertFrom-Json +if (@($miniMap.districts).Count -ne 13) { throw "Mini World District count mismatch." } +if ($miniStatus.technicalStatus -ne 'PASS' -or $miniStatus.visualStatus -ne 'PARTIAL' -or $miniStatus.releaseGate -ne 'PARTIAL') { + throw "Mini World status contract failed." +} +$miniAsset = [regex]::Match($miniRoot.Content, '/archive-world-mini/assets/[^"''? ]+') +if (-not $miniAsset.Success) { throw "Mini World HTML has no hashed asset reference." } +ExpectStatus -Path $miniAsset.Value -Method GET -Expected 200 +$publicSession = Json -Path "/api/auth/session" +if ($publicSession.data.role -ne "PUBLIC") { throw "Public session contract failed." } +$health = Json -Path "/api/health" +if ($health.status -ne "ok") { throw "Backend health contract failed." } +[void](Json -Path "/api/platform/readiness") + +$managed = (Json -Path "/api/managed-systems").data +if (@($managed).Count -ne 5) { throw "Managed Systems must contain exactly five systems." } +$ids = @($managed.systemId) +if ($ids -contains "archive-logitics" -or $ids -contains "archive-world" -or + $ids -contains "atlas-platform" -or $ids -contains "deepstake") { + throw "Managed Systems canonical identity contract failed." +} +foreach ($requiredSystem in @("archive-os", "archive-market", "archive-nexus", "archive-logistics", "archive-ledger")) { + if ($ids -notcontains $requiredSystem) { throw "Managed Systems is missing $requiredSystem." } +} +[void](Json -Path "/api/managed-systems/overview") +[void](Json -Path "/api/managed-systems/archive-logistics") +[void](Json -Path "/api/managed-systems/archive-logistics/events") +[void](Json -Path "/api/managed-systems/archive-logistics/workflows") +[void](Json -Path "/api/managed-systems/archive-logistics/work-logs") + +$inbox = @((Json -Path "/api/pm-inbox").data) +$probe = if ($inbox.Count) { $inbox[0].id } else { "missing-verification-item" } +ExpectStatus -Path "/api/pm-inbox/$probe/acknowledge" -Method POST -Expected 401 + +$password = [Environment]::GetEnvironmentVariable($AdminPasswordEnvironmentVariable) +if ([string]::IsNullOrWhiteSpace($password)) { throw "Admin credential environment variable is unavailable." } +$pm = New-Object Microsoft.PowerShell.Commands.WebRequestSession +ExpectStatus -Path "/api/auth/login" -Method POST -Expected 200 -Session $pm -Body @{ username = "admin"; password = $password; role = "PM" } +ExpectStatus -Path "/api/pm-inbox/$probe/acknowledge" -Method POST -Expected 403 -Session $pm + +$admin = New-Object Microsoft.PowerShell.Commands.WebRequestSession +ExpectStatus -Path "/api/auth/login" -Method POST -Expected 200 -Session $admin -Body @{ username = "admin"; password = $password; role = "ADMIN" } +$password = $null + +if ($inbox.Count) { + ExpectStatus -Path "/api/pm-inbox/$probe/acknowledge" -Method POST -Expected 200 -Session $admin + ExpectStatus -Path "/api/pm-inbox/$probe/acknowledge" -Method POST -Expected 200 -Session $admin + ExpectStatus -Path "/api/pm-inbox/$probe/resolve" -Method POST -Expected 200 -Session $admin + ExpectStatus -Path "/api/pm-inbox/$probe/resolve" -Method POST -Expected 200 -Session $admin +} + +$runtime = (Json -Path "/api/ai/runtime" -Session $admin).data +$worldState = (Json -Path "/api/world/state" -Session $admin).data +$worldEvents = (Json -Path "/api/world/events?limit=100" -Session $admin).data +if ($worldState.mode -ne 'LIVE' -or $worldState.readOnly -ne $true -or $worldState.manifestStatus -ne 'READY') { + throw "World Adapter live/read-only manifest contract failed." +} +if (@($worldEvents.events).Count -lt 1 -or [string]::IsNullOrWhiteSpace([string](@($worldEvents.events)[0].eventId))) { + throw "World Adapter has no persisted Runtime event evidence." +} +$worldStream = Test-Sse -Path "/api/world/stream" -Headers @{} -Session $admin -ExpectedEvent 'world-state' +$modelCheck = Json -Path "/api/ai/runtime/check" -Method POST -Session $admin +if (-not $runtime.vectorStore.databaseConnected -or -not $runtime.vectorStore.extensionInstalled -or -not $runtime.vectorStore.indexReady) { + throw "pgvector readiness failed." +} +if ($runtime.embeddingModel.dimensions -ne 1536 -or -not $modelCheck.embedding.success -or -not $modelCheck.chat.success) { + throw "OpenAI model smoke failed." +} +if (-not $runtime.chatModel.beanAvailable -or -not $runtime.embeddingModel.beanAvailable -or -not $runtime.rag.ready) { + throw "AI runtime is not ready for real RAG." +} + +$sync = (Json -Path "/api/obsidian/sync" -Method POST -Session $admin).data +$documents = @((Json -Path "/api/obsidian/documents?limit=100" -Session $admin).data) +if ($documents.Count -lt 1 -or $runtime.knowledge.failedEmbeddings -ne 0) { + throw "Obsidian synchronization evidence failed." +} + +$search = (Json -Path "/api/rag/search?query=ArchiveOS&limit=5" -Session $admin).data +if (@($search).Count -lt 1) { throw "RAG search returned no evidence." } +$firstSearch = @($search)[0] +if ($null -eq $firstSearch.score -or + [string]::IsNullOrWhiteSpace([string]$firstSearch.title) -or + [string]::IsNullOrWhiteSpace([string]$firstSearch.path)) { + throw "RAG search result lacks evidence metadata." +} +$ask = (Json -Path "/api/rag/ask" -Method POST -Body @{ + question = "Summarize the ArchiveOS Spring AI RAG architecture." +} -Session $admin).data +if ([string]::IsNullOrWhiteSpace($ask.answer) -or @($ask.references).Count -lt 1) { throw "RAG answer evidence failed." } + +$jobs = (Json -Path "/api/batch/jobs" -Session $admin).data +if (-not ($jobs | Where-Object name -eq "ragHealthCheckJob")) { throw "Required Spring Batch job is missing." } +$batchRun = (Json -Path "/api/batch/jobs/ragHealthCheckJob/run" -Method POST -Session $admin).data +if ($batchRun.status -notin @("COMPLETED", "STARTED", "STARTING")) { throw "Spring Batch launch failed." } +$batchExecutions = @((Json -Path "/api/batch/executions?limit=10" -Session $admin).data) +if (-not ($batchExecutions | Where-Object { + $_.jobName -eq "ragHealthCheckJob" -and $_.status -eq "COMPLETED" +})) { + throw "No successful ragHealthCheckJob execution was recorded." +} + +$rpa = (Json -Path "/api/rpa/classify" -Method POST -Session $admin -Body @{ + title = "OCI full-stack canary classification" + description = "Classify a canary validation record without executing a shell command." + targetProject = "ArchiveOS" + requestedBy = "verify-fullstack" +}).data +if (-not $rpa.data) { $rpaData = $rpa } else { $rpaData = $rpa.data } +if (-not $rpaData.task.approvalRequired -or + $rpaData.safety -notmatch "classification_only|approval_required") { + throw "RPA classification safety contract failed." +} +$rpaDecision = (Json -Path "/api/rpa/tasks/$($rpaData.task.id)/decision" -Method POST -Session $admin -Body @{ + action = "approve" + reason = "Canary validation records the decision only; no shell execution is authorized." + decidedBy = "verify-fullstack" +}).data +if ($rpaDecision.decision.action -ne "approve") { throw "RPA decision record failed." } + +[void](Json -Path "/api/audit/logs?limit=20" -Session $admin) +[void](Json -Path "/api/knowledge/health" -Session $admin) +[void](Json -Path "/api/knowledge/overview" -Session $admin) +[void](Json -Path "/api/knowledge/recent?limit=10" -Session $admin) +[void](Json -Path "/api/runtime/timeline?limit=10" -Session $admin) +[void](Json -Path "/api/live-flow/summary") +[void](Json -Path "/api/live-flow/topology") +[void](Json -Path "/api/live-flow/events/recent?limit=10" -Session $admin) + +$token = [Environment]::GetEnvironmentVariable($ReadTokenEnvironmentVariable) +if ([string]::IsNullOrWhiteSpace($token)) { throw "Read token environment variable is unavailable." } +$headers = @{ + Authorization = "Bearer $token" + "X-Archive-Source-System" = "archive-os" + "X-Archive-Service-Scope" = "authenticated:read" +} +$timeline = (Json -Path "/api/correlation-timeline/$CorrelationId" -Headers $headers).data +$liveCorrelation = (Json -Path "/api/live-flow/correlation/$CorrelationId" -Headers $headers).data +$ssePass = Test-Sse -Path "/api/live-flow/stream" -Headers @{} -Session $admin +$token = $null +if (@($timeline.events).Count -ne 35) { throw "Official correlation event count mismatch." } +if (@($liveCorrelation).Count -ne 35) { throw "Live Flow correlation event count mismatch." } +if (@($timeline.lineage.observedServices).Count -ne 4) { throw "Official correlation source count mismatch." } +if ($timeline.lineage.chainStatus -ne "COMPLETE_CHAIN") { throw "Official correlation is not COMPLETE_CHAIN." } +if (@($timeline.events | Where-Object causationStatus -eq "ROOT_EVENT").Count -ne 1) { + throw "Official correlation ROOT_EVENT count mismatch." +} +if (@($timeline.events | Where-Object causationStatus -eq "EXTERNAL_PARENT_NOT_INGESTED").Count -ne 0) { + throw "Official correlation has an external parent gap." +} +if (@($timeline.events | Where-Object causationStatus -eq "INVALID_CAUSATION").Count -ne 0) { + throw "Official correlation has invalid causation." +} +$eventIds = @($timeline.events.eventId) +if (@($eventIds | Sort-Object -Unique).Count -ne $eventIds.Count) { + throw "Official correlation contains duplicate event IDs." +} +if ($timeline.lineage.simulationRunIdDistinctCount -ne 1) { + throw "Official correlation simulationRunId contract failed." +} + +[pscustomobject]@{ + Status = "FULLSTACK_SMOKE_PASS" + ManagedSystems = @($managed).Count + PmInboxActionsExecuted = [bool]$inbox.Count + DatabaseConnected = $runtime.vectorStore.databaseConnected + VectorIndexReady = $runtime.vectorStore.indexReady + EmbeddingDimensions = $runtime.embeddingModel.dimensions + RagSearchCount = @($search).Count + RagAnswerPresent = -not [string]::IsNullOrWhiteSpace($ask.answer) + RagReferenceCount = @($ask.references).Count + ObsidianDocuments = $documents.Count + ObsidianSyncCompleted = ($null -ne $sync) + BatchJobStatus = $batchRun.status + RpaApprovalRequired = $rpaData.task.approvalRequired + RpaDecisionRecorded = ($rpaDecision.decision.action -eq "approve") + LiveFlowSseEvent = $ssePass + WorldAdapterMode = $worldState.mode + WorldEvents = @($worldEvents.events).Count + WorldSseEvent = $worldStream + WorldDistricts = @($miniMap.districts).Count + CorrelationEvents = @($timeline.events).Count + CorrelationSources = @($timeline.lineage.observedServices).Count + CorrelationStatus = $timeline.lineage.chainStatus +} diff --git a/deploy/oci/fullstack/scripts/verify-world-handoff.ps1 b/deploy/oci/fullstack/scripts/verify-world-handoff.ps1 new file mode 100644 index 0000000..4e5e08c --- /dev/null +++ b/deploy/oci/fullstack/scripts/verify-world-handoff.ps1 @@ -0,0 +1,121 @@ +param( + [Parameter(Mandatory = $true)][string]$HandoffRoot, + [Parameter(Mandatory = $true)][string]$MiniArtifactRoot, + [Parameter(Mandatory = $true)][ValidatePattern('^[0-9a-f]{40}$')][string]$WorldSourceHead, + [Parameter(Mandatory = $true)][ValidatePattern('^.+@sha256:[0-9a-f]{64}$')][string]$ImageDigest, + [string]$ManifestPath, + [string]$ProvenancePath +) + +$ErrorActionPreference = 'Stop' +if ([string]::IsNullOrWhiteSpace($ManifestPath)) { $ManifestPath = Join-Path $HandoffRoot 'archive-world-assets.json' } +if ([string]::IsNullOrWhiteSpace($ProvenancePath)) { $ProvenancePath = Join-Path $HandoffRoot 'provenance.json' } + +function Require-File([string]$Path, [string]$Label) { + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { throw "BLOCKED_WORLD_HANDOFF: missing $Label" } + (Resolve-Path -LiteralPath $Path).Path +} + +function Read-Json([string]$Path, [string]$Label) { + try { Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json } + catch { throw "BLOCKED_WORLD_HANDOFF: invalid JSON for $Label" } +} + +function Count-Array($Object, [string]$Name) { + $value = $Object.$Name + if ($null -eq $value) { throw "BLOCKED_WORLD_HANDOFF: $Name is absent from world-mini-map.json" } + @($value).Count +} + +function Require-ZeroMetric($Object, [string[]]$Names) { + foreach ($name in $Names) { + $value = $Object.$name + if ($null -ne $value) { + if ([int64]$value -ne 0) { throw "BLOCKED_WORLD_HANDOFF: $name must be 0" } + return + } + } + throw "BLOCKED_WORLD_HANDOFF: validation metric is absent ($($Names -join ', '))" +} + +$handoff = Resolve-Path -LiteralPath $HandoffRoot +$mini = Resolve-Path -LiteralPath $MiniArtifactRoot +Require-File (Join-Path $mini 'web/dist/archive-world-mini/index.html') 'Mini World index.html' | Out-Null +$assets = Join-Path $mini 'web/dist/archive-world-mini/assets' +if (-not (Test-Path -LiteralPath $assets -PathType Container) -or @(Get-ChildItem -LiteralPath $assets -File -Recurse).Count -lt 1) { + throw 'BLOCKED_WORLD_HANDOFF: Mini World assets are missing' +} +$mapPath = Require-File (Join-Path $mini 'web/public/archive-world-mini/world-mini-map.json') 'world-mini-map.json' +$statusPath = Require-File (Join-Path $mini 'web/public/archive-world-mini/status.json') 'status.json' +Require-File (Join-Path $mini 'archive-world-live-snapshot-20260730.json') 'live snapshot' | Out-Null +Require-File (Join-Path $mini 'archive-world-current-state-20260730.json') 'current state JSON' | Out-Null +Require-File (Join-Path $mini 'archive-world-current-state-20260730.md') 'current state Markdown' | Out-Null +$manifestPath = Require-File $ManifestPath 'ArchiveOS adapter manifest' +$provenancePath = Require-File $ProvenancePath 'World PM provenance' + +$manifestHash = (Get-FileHash -LiteralPath $manifestPath -Algorithm SHA256).Hash.ToLowerInvariant() +$mapHash = (Get-FileHash -LiteralPath $mapPath -Algorithm SHA256).Hash.ToLowerInvariant() +$statusHash = (Get-FileHash -LiteralPath $statusPath -Algorithm SHA256).Hash.ToLowerInvariant() +$provenance = Read-Json $provenancePath 'provenance' +$map = Read-Json $mapPath 'world-mini-map' +$status = Read-Json $statusPath 'status' + +if ($provenance.schemaVersion -ne '1.0.0' -or $provenance.sourceProject -ne 'Archive-World' -or + $provenance.sourceHead -ne $WorldSourceHead -or $provenance.actualBuildEvidence -ne $true -or + $provenance.prototypeTarget -ne $false) { + throw 'BLOCKED_WORLD_HANDOFF: provenance identity or actual-build evidence failed' +} +if ($provenance.manifestSha256 -ne $manifestHash -or $provenance.miniMapSha256 -ne $mapHash -or + $provenance.statusSha256 -ne $statusHash) { + throw 'BLOCKED_WORLD_SOURCE_MISMATCH: World PM checksum evidence failed' +} +foreach ($flag in @('canonical', 'v3Applied', 'runtimeMutation', 'mainMerge')) { + if ($provenance.$flag -ne $false) { throw "BLOCKED_WORLD_HANDOFF: protection flag $flag is not false" } +} + +if ([string]::IsNullOrWhiteSpace([string]$provenance.bundlePath) -or + [string]::IsNullOrWhiteSpace([string]$provenance.bundleSha256)) { + throw 'BLOCKED_WORLD_HANDOFF: provenance must include bundlePath and bundleSha256' +} +$bundlePath = [IO.Path]::GetFullPath((Join-Path $mini $provenance.bundlePath)) +$miniPrefix = $mini.Path.TrimEnd([char[]]@('\', '/')) + [IO.Path]::DirectorySeparatorChar +if (-not $bundlePath.StartsWith($miniPrefix, [StringComparison]::OrdinalIgnoreCase)) { + throw 'BLOCKED_WORLD_HANDOFF: bundlePath escapes the immutable artifact root' +} +Require-File $bundlePath 'provenance bundlePath' | Out-Null +if ((Get-FileHash -LiteralPath $bundlePath -Algorithm SHA256).Hash.ToLowerInvariant() -ne $provenance.bundleSha256.ToLowerInvariant()) { + throw 'BLOCKED_WORLD_SOURCE_MISMATCH: bundle checksum failed' +} + +$districts = Count-Array $map 'districts' +$blocks = Count-Array $map 'blocks' +$buildings = Count-Array $map 'buildings' +$families = Count-Array $map 'families' +$chunks = if ($null -ne $map.lodChunks) { @($map.lodChunks).Count } else { Count-Array $map 'chunks' } +if ($districts -ne 13) { throw "BLOCKED_WORLD_HANDOFF: expected 13 Districts, found $districts" } +$validation = $map.validation +if ($null -eq $validation) { throw 'BLOCKED_WORLD_HANDOFF: map validation section is absent' } +Require-ZeroMetric $validation @('invalidCoordinates', 'invalidCoordinateCount') +Require-ZeroMetric $validation @('negativeScales', 'negativeScaleCount') +Require-ZeroMetric $validation @('anchorMismatch', 'anchorMismatchCount') +if ($status.technicalStatus -ne 'PASS' -or $status.visualStatus -ne 'PARTIAL' -or $status.releaseGate -ne 'PARTIAL') { + throw 'BLOCKED_WORLD_HANDOFF: World release status does not match the approved current state' +} + +[pscustomobject]@{ + Status = 'WORLD_SOURCE_PROVENANCE_VERIFIED' + ArtifactImage = $ImageDigest + WorldSourceHead = $WorldSourceHead + ManifestSha256 = $manifestHash + MiniMapSha256 = $mapHash + StatusSha256 = $statusHash + District = $districts + Block = $blocks + Building = $buildings + Family = $families + Chunk = $chunks + Technical = $status.technicalStatus + Visual = $status.visualStatus + Release = $status.releaseGate + ProtectionFlagsPreserved = $true +} diff --git a/deploy/oci/fullstack/systemd/archiveos-secret-loader.service b/deploy/oci/fullstack/systemd/archiveos-secret-loader.service new file mode 100644 index 0000000..0a372d6 --- /dev/null +++ b/deploy/oci/fullstack/systemd/archiveos-secret-loader.service @@ -0,0 +1,17 @@ +[Unit] +Description=Load ArchiveOS secrets from OCI Secret Management +After=network-online.target +Wants=network-online.target +Before=archiveos.service + +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=/opt/archiveos/deploy/oci/fullstack/scripts/fetch-secrets.sh +ExecStop=/opt/archiveos/deploy/oci/fullstack/scripts/fetch-secrets.sh --delete +User=root +Group=root +UMask=0077 + +[Install] +WantedBy=multi-user.target diff --git a/deploy/oci/fullstack/systemd/archiveos.service b/deploy/oci/fullstack/systemd/archiveos.service new file mode 100644 index 0000000..67d6090 --- /dev/null +++ b/deploy/oci/fullstack/systemd/archiveos.service @@ -0,0 +1,20 @@ +[Unit] +Description=ArchiveOS OCI full-stack +Requires=docker.service archiveos-secret-loader.service +After=docker.service network-online.target archiveos-secret-loader.service +Wants=network-online.target + +[Service] +Type=oneshot +RemainAfterExit=yes +WorkingDirectory=/opt/archiveos +Environment=COMPOSE_PROJECT_NAME=archiveos-oci +ExecStartPre=/opt/archiveos/deploy/oci/fullstack/scripts/preflight.sh +ExecStart=/usr/bin/docker compose --env-file /run/archiveos/archiveos.env -f docker-compose.yml -f docker-compose.rc.yml -f deploy/oci/fullstack/docker-compose.oci.yml up -d --pull always --wait +ExecStop=/usr/bin/docker compose --env-file /run/archiveos/archiveos.env -f docker-compose.yml -f docker-compose.rc.yml -f deploy/oci/fullstack/docker-compose.oci.yml down +ExecStopPost=/usr/bin/rm -f /run/archiveos/archiveos.env +TimeoutStartSec=900 +TimeoutStopSec=180 + +[Install] +WantedBy=multi-user.target diff --git a/deploy/oci/fullstack/terraform/compute.tf b/deploy/oci/fullstack/terraform/compute.tf new file mode 100644 index 0000000..0f1b958 --- /dev/null +++ b/deploy/oci/fullstack/terraform/compute.tf @@ -0,0 +1,56 @@ +data "oci_identity_availability_domains" "available" { + compartment_id = var.tenancy_ocid +} + +resource "oci_core_instance" "archiveos" { + availability_domain = data.oci_identity_availability_domains.available.availability_domains[0].name + compartment_id = var.compartment_ocid + display_name = "archiveos-canary" + shape = var.compute_shape + preserve_boot_volume = true + + shape_config { + ocpus = var.compute_ocpus + memory_in_gbs = var.compute_memory_gb + } + + source_details { + source_type = "image" + source_id = var.compute_image_id + boot_volume_size_in_gbs = var.boot_volume_gb + } + + create_vnic_details { + subnet_id = oci_core_subnet.private.id + assign_public_ip = false + hostname_label = "archiveos-canary" + nsg_ids = [oci_core_network_security_group.compute.id] + } + + metadata = { + ssh_authorized_keys = var.ssh_authorized_keys + user_data = base64encode(<<-CLOUD_INIT + #cloud-config + package_update: true + packages: + - jq + - xfsprogs + runcmd: + - [mkdir, -p, /opt/archiveos, /srv/archiveos] + - [chmod, "0750", /opt/archiveos] + final_message: "ArchiveOS canary host bootstrap complete; deployment remains operator-gated." + CLOUD_INIT + ) + } + + freeform_tags = var.freeform_tags +} + +data "oci_core_vnic_attachments" "archiveos" { + compartment_id = var.compartment_ocid + instance_id = oci_core_instance.archiveos.id +} + +data "oci_core_vnic" "archiveos" { + vnic_id = data.oci_core_vnic_attachments.archiveos.vnic_attachments[0].vnic_id +} diff --git a/deploy/oci/fullstack/terraform/iam.tf b/deploy/oci/fullstack/terraform/iam.tf new file mode 100644 index 0000000..452d20b --- /dev/null +++ b/deploy/oci/fullstack/terraform/iam.tf @@ -0,0 +1,20 @@ +resource "oci_identity_dynamic_group" "archiveos" { + compartment_id = var.tenancy_ocid + name = "archiveos-canary-instances" + description = "ArchiveOS canary Compute instance principal." + matching_rule = "ALL {instance.id = '${oci_core_instance.archiveos.id}'}" + freeform_tags = var.freeform_tags +} + +resource "oci_identity_policy" "archiveos" { + compartment_id = var.tenancy_ocid + name = "archiveos-canary-instance-policy" + description = "Least-privilege reads and backup writes for ArchiveOS canary." + + statements = [ + "Allow dynamic-group ${oci_identity_dynamic_group.archiveos.name} to read secret-bundles in compartment id ${var.compartment_ocid}", + "Allow dynamic-group ${oci_identity_dynamic_group.archiveos.name} to read repos in compartment id ${var.compartment_ocid}", + "Allow dynamic-group ${oci_identity_dynamic_group.archiveos.name} to manage objects in compartment id ${var.compartment_ocid} where target.bucket.name='${oci_objectstorage_bucket.backups.name}'", + "Allow dynamic-group ${oci_identity_dynamic_group.archiveos.name} to read buckets in compartment id ${var.compartment_ocid}" + ] +} diff --git a/deploy/oci/fullstack/terraform/load-balancer.tf b/deploy/oci/fullstack/terraform/load-balancer.tf new file mode 100644 index 0000000..a664467 --- /dev/null +++ b/deploy/oci/fullstack/terraform/load-balancer.tf @@ -0,0 +1,80 @@ +resource "oci_load_balancer_load_balancer" "archiveos" { + compartment_id = var.compartment_ocid + display_name = "archiveos-canary-lb" + shape = "flexible" + subnet_ids = [var.public_subnet_id] + network_security_group_ids = [ + oci_core_network_security_group.load_balancer.id + ] + + shape_details { + minimum_bandwidth_in_mbps = var.load_balancer_min_mbps + maximum_bandwidth_in_mbps = var.load_balancer_max_mbps + } + + freeform_tags = var.freeform_tags +} + +resource "oci_load_balancer_backend_set" "archiveos" { + load_balancer_id = oci_load_balancer_load_balancer.archiveos.id + name = "archiveos-canary-backends" + policy = "ROUND_ROBIN" + + health_checker { + protocol = "HTTP" + port = 8080 + url_path = "/health" + return_code = 200 + retries = 3 + timeout_in_millis = 3000 + interval_ms = 10000 + } +} + +resource "oci_load_balancer_backend" "archiveos" { + load_balancer_id = oci_load_balancer_load_balancer.archiveos.id + backendset_name = oci_load_balancer_backend_set.archiveos.name + ip_address = data.oci_core_vnic.archiveos.private_ip_address + port = 8080 + weight = 1 +} + +resource "oci_load_balancer_listener" "https" { + load_balancer_id = oci_load_balancer_load_balancer.archiveos.id + name = "archiveos-canary-https" + default_backend_set_name = oci_load_balancer_backend_set.archiveos.name + port = 443 + protocol = "HTTP" + + ssl_configuration { + certificate_ids = [var.certificate_id] + verify_peer_certificate = false + protocols = ["TLSv1.2", "TLSv1.3"] + } +} + +resource "oci_load_balancer_rule_set" "https_redirect" { + load_balancer_id = oci_load_balancer_load_balancer.archiveos.id + name = "archiveos-canary-https-redirect" + + items { + action = "REDIRECT" + response_code = 301 + redirect_uri { + protocol = "HTTPS" + host = "{host}" + port = 443 + path = "{path}" + query = "{query}" + } + } +} + +resource "oci_load_balancer_listener" "http" { + load_balancer_id = oci_load_balancer_load_balancer.archiveos.id + name = "archiveos-canary-http" + default_backend_set_name = oci_load_balancer_backend_set.archiveos.name + port = 80 + protocol = "HTTP" + rule_set_names = [oci_load_balancer_rule_set.https_redirect.name] +} diff --git a/deploy/oci/fullstack/terraform/network.tf b/deploy/oci/fullstack/terraform/network.tf new file mode 100644 index 0000000..59da0ce --- /dev/null +++ b/deploy/oci/fullstack/terraform/network.tf @@ -0,0 +1,144 @@ +data "oci_core_services" "osn" { + filter { + name = "name" + values = [".*All .* Services In Oracle Services Network"] + regex = true + } +} + +resource "oci_core_nat_gateway" "archiveos" { + compartment_id = var.compartment_ocid + vcn_id = var.vcn_id + display_name = "archiveos-canary-nat" + freeform_tags = var.freeform_tags +} + +resource "oci_core_service_gateway" "archiveos" { + compartment_id = var.compartment_ocid + vcn_id = var.vcn_id + display_name = "archiveos-canary-service-gateway" + services { + service_id = data.oci_core_services.osn.services[0].id + } + freeform_tags = var.freeform_tags +} + +resource "oci_core_route_table" "private" { + compartment_id = var.compartment_ocid + vcn_id = var.vcn_id + display_name = "archiveos-canary-private-routes" + + route_rules { + destination = "0.0.0.0/0" + destination_type = "CIDR_BLOCK" + network_entity_id = oci_core_nat_gateway.archiveos.id + } + + route_rules { + destination = data.oci_core_services.osn.services[0].cidr_block + destination_type = "SERVICE_CIDR_BLOCK" + network_entity_id = oci_core_service_gateway.archiveos.id + } + + freeform_tags = var.freeform_tags +} + +resource "oci_core_subnet" "private" { + compartment_id = var.compartment_ocid + vcn_id = var.vcn_id + cidr_block = "10.42.20.0/24" + display_name = "archiveos-canary-private-subnet" + dns_label = "archiveosprivate" + prohibit_public_ip_on_vnic = true + route_table_id = oci_core_route_table.private.id + freeform_tags = var.freeform_tags +} + +resource "oci_core_network_security_group" "load_balancer" { + compartment_id = var.compartment_ocid + vcn_id = var.vcn_id + display_name = "archiveos-canary-lb-nsg" + freeform_tags = var.freeform_tags +} + +resource "oci_core_network_security_group_security_rule" "lb_https" { + for_each = var.canary_allowed_cidrs + network_security_group_id = oci_core_network_security_group.load_balancer.id + direction = "INGRESS" + protocol = "6" + source = each.value + source_type = "CIDR_BLOCK" + tcp_options { + destination_port_range { + min = 443 + max = 443 + } + } +} + +resource "oci_core_network_security_group_security_rule" "lb_http" { + for_each = var.canary_allowed_cidrs + network_security_group_id = oci_core_network_security_group.load_balancer.id + direction = "INGRESS" + protocol = "6" + source = each.value + source_type = "CIDR_BLOCK" + tcp_options { + destination_port_range { + min = 80 + max = 80 + } + } +} + +resource "oci_core_network_security_group_security_rule" "lb_egress" { + network_security_group_id = oci_core_network_security_group.load_balancer.id + direction = "EGRESS" + protocol = "all" + destination = "0.0.0.0/0" + destination_type = "CIDR_BLOCK" +} + +resource "oci_core_network_security_group" "compute" { + compartment_id = var.compartment_ocid + vcn_id = var.vcn_id + display_name = "archiveos-canary-compute-nsg" + freeform_tags = var.freeform_tags +} + +resource "oci_core_network_security_group_security_rule" "compute_frontend" { + network_security_group_id = oci_core_network_security_group.compute.id + direction = "INGRESS" + protocol = "6" + source = oci_core_network_security_group.load_balancer.id + source_type = "NETWORK_SECURITY_GROUP" + tcp_options { + destination_port_range { + min = 8080 + max = 8080 + } + } +} + +resource "oci_core_network_security_group_security_rule" "compute_ssh" { + count = var.management_cidr == "" ? 0 : 1 + network_security_group_id = oci_core_network_security_group.compute.id + direction = "INGRESS" + protocol = "6" + source = var.management_cidr + source_type = "CIDR_BLOCK" + tcp_options { + destination_port_range { + min = 22 + max = 22 + } + } +} + +resource "oci_core_network_security_group_security_rule" "compute_egress" { + network_security_group_id = oci_core_network_security_group.compute.id + direction = "EGRESS" + protocol = "all" + destination = "0.0.0.0/0" + destination_type = "CIDR_BLOCK" +} diff --git a/deploy/oci/fullstack/terraform/outputs.tf b/deploy/oci/fullstack/terraform/outputs.tf new file mode 100644 index 0000000..20b6527 --- /dev/null +++ b/deploy/oci/fullstack/terraform/outputs.tf @@ -0,0 +1,30 @@ +output "compute_instance_id" { + value = oci_core_instance.archiveos.id + sensitive = true +} + +output "compute_private_ip" { + value = data.oci_core_vnic.archiveos.private_ip_address +} + +output "private_subnet_id" { + value = oci_core_subnet.private.id + sensitive = true +} + +output "block_volume_id" { + value = oci_core_volume.archiveos.id + sensitive = true +} + +output "load_balancer_ip" { + value = oci_load_balancer_load_balancer.archiveos.ip_address_details[0].ip_address +} + +output "backup_bucket_name" { + value = oci_objectstorage_bucket.backups.name +} + +output "canary_hostname" { + value = var.canary_hostname +} diff --git a/deploy/oci/fullstack/terraform/storage.tf b/deploy/oci/fullstack/terraform/storage.tf new file mode 100644 index 0000000..2c406a1 --- /dev/null +++ b/deploy/oci/fullstack/terraform/storage.tf @@ -0,0 +1,29 @@ +resource "oci_core_volume" "archiveos" { + availability_domain = oci_core_instance.archiveos.availability_domain + compartment_id = var.compartment_ocid + display_name = "archiveos-canary-data" + size_in_gbs = var.data_volume_gb + vpus_per_gb = var.data_volume_vpus_per_gb + freeform_tags = var.freeform_tags +} + +resource "oci_core_volume_attachment" "archiveos" { + attachment_type = "paravirtualized" + instance_id = oci_core_instance.archiveos.id + volume_id = oci_core_volume.archiveos.id + display_name = "archiveos-canary-data-attachment" +} + +resource "oci_objectstorage_bucket" "backups" { + compartment_id = var.compartment_ocid + name = var.backup_bucket_name + namespace = data.oci_objectstorage_namespace.current.namespace + access_type = "NoPublicAccess" + storage_tier = "Standard" + versioning = "Enabled" + freeform_tags = var.freeform_tags +} + +data "oci_objectstorage_namespace" "current" { + compartment_id = var.compartment_ocid +} diff --git a/deploy/oci/fullstack/terraform/terraform.tfvars.example b/deploy/oci/fullstack/terraform/terraform.tfvars.example new file mode 100644 index 0000000..e6cede2 --- /dev/null +++ b/deploy/oci/fullstack/terraform/terraform.tfvars.example @@ -0,0 +1,22 @@ +tenancy_ocid = "ocid1.tenancy.oc1..replace" +compartment_ocid = "ocid1.compartment.oc1..replace" +region = "ap-osaka-1" +vcn_id = "ocid1.vcn.oc1.ap-osaka-1.replace" +public_subnet_id = "ocid1.subnet.oc1.ap-osaka-1.replace" +compute_image_id = "ocid1.image.oc1.ap-osaka-1.replace" +ssh_authorized_keys = "ssh-ed25519 REPLACE_WITH_APPROVED_PUBLIC_KEY" +management_cidr = "203.0.113.10/32" +canary_allowed_cidrs = ["203.0.113.10/32"] +certificate_id = "ocid1.certificate.oc1.ap-osaka-1.replace" +canary_hostname = "archiveos-canary.example.invalid" +backup_bucket_name = "archiveos-private-backups" + +# Paid defaults requiring explicit approval: +compute_shape = "VM.Standard.E4.Flex" +compute_ocpus = 2 +compute_memory_gb = 16 +boot_volume_gb = 100 +data_volume_gb = 200 +data_volume_vpus_per_gb = 10 +load_balancer_min_mbps = 10 +load_balancer_max_mbps = 10 diff --git a/deploy/oci/fullstack/terraform/variables.tf b/deploy/oci/fullstack/terraform/variables.tf new file mode 100644 index 0000000..a67faab --- /dev/null +++ b/deploy/oci/fullstack/terraform/variables.tf @@ -0,0 +1,115 @@ +variable "tenancy_ocid" { + type = string + description = "OCI tenancy OCID." +} + +variable "compartment_ocid" { + type = string + description = "Compartment used for ArchiveOS OCI resources." +} + +variable "region" { + type = string + description = "OCI region." + default = "ap-osaka-1" +} + +variable "vcn_id" { + type = string + description = "Existing Archive VCN OCID." +} + +variable "public_subnet_id" { + type = string + description = "Existing public subnet used only by the Load Balancer." +} + +variable "compute_image_id" { + type = string + description = "Approved Oracle Linux compute image OCID." +} + +variable "ssh_authorized_keys" { + type = string + description = "Approved SSH public key material." +} + +variable "management_cidr" { + type = string + description = "Approved bastion or management CIDR. Leave empty to disable direct SSH ingress." + default = "" +} + +variable "canary_allowed_cidrs" { + type = set(string) + description = "Approved CIDRs allowed to reach the canary Load Balancer. Public 0.0.0.0/0 is prohibited." + + validation { + condition = length(var.canary_allowed_cidrs) > 0 && !contains(var.canary_allowed_cidrs, "0.0.0.0/0") + error_message = "Provide at least one approved canary CIDR; public Internet ingress is prohibited." + } +} + +variable "certificate_id" { + type = string + description = "OCI Certificates service certificate OCID for the canary hostname." +} + +variable "canary_hostname" { + type = string + description = "Temporary canary hostname. Production DNS is out of scope." +} + +variable "compute_shape" { + type = string + default = "VM.Standard.E4.Flex" +} + +variable "compute_ocpus" { + type = number + default = 2 +} + +variable "compute_memory_gb" { + type = number + default = 16 +} + +variable "boot_volume_gb" { + type = number + default = 100 +} + +variable "data_volume_gb" { + type = number + default = 200 +} + +variable "data_volume_vpus_per_gb" { + type = number + default = 10 +} + +variable "load_balancer_min_mbps" { + type = number + default = 10 +} + +variable "load_balancer_max_mbps" { + type = number + default = 10 +} + +variable "backup_bucket_name" { + type = string + default = "archiveos-private-backups" +} + +variable "freeform_tags" { + type = map(string) + default = { + project = "ArchiveOS" + environment = "canary" + source = "c2324df9a935e897fbe8be94068f862c4ac3956f" + } +} diff --git a/deploy/oci/fullstack/terraform/versions.tf b/deploy/oci/fullstack/terraform/versions.tf new file mode 100644 index 0000000..6643301 --- /dev/null +++ b/deploy/oci/fullstack/terraform/versions.tf @@ -0,0 +1,14 @@ +terraform { + required_version = ">= 1.7.0" + + required_providers { + oci = { + source = "oracle/oci" + version = "~> 7.0" + } + } +} + +provider "oci" { + region = var.region +}