ops: add CI/CD pipeline, a/b rolling deploy, Gitea Actions workflow
Deploy to Production / deploy (push) Failing after 10s

- .gitea/workflows/deploy.yml — push-to-main triggers rolling deploy
- scripts/deploy-bluegreen.sh — a-stack then b-stack restart; Maven runs
  in Docker (no JDK needed on runner host); Caddy reload at end
- scripts/deploy-all.ps1 — emergency manual deploy from dev machine
- infra/docker-compose.yml — a/b pairs per service; wget health checks;
  Gitea service; Prometheus/Grafana/DB ports restricted to localhost
- infra/Caddyfile — dual upstreams with health-based routing
- infra/Dockerfile.* — one per service
- infra/prometheus.yml + grafana provisioning

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Carsten Rehfeld
2026-05-14 14:01:12 +02:00
parent 5156089152
commit 82f0ac6007
72 changed files with 4715 additions and 27 deletions
@@ -23,8 +23,22 @@ quarkus.http.port=8180
apix.api-key=${APIX_API_KEY:dev-insecure-key-change-in-prod}
# ── Verification ──────────────────────────────────────────────────────────────
apix.dns.doh-url=${APIX_DOH_URL:https://dns.google/resolve}
apix.gleif.api-url=${GLEIF_API_URL:https://api.gleif.org/api/v1}
apix.opencorporates.api-key=${OPENCORPORATES_API_KEY:}
apix.opencorporates.api-url=${OPENCORPORATES_API_URL:https://api.opencorporates.com/v0.4}
apix.hygiene.security-txt-url-template=${APIX_SECURITY_TXT_TEMPLATE:https://{domain}/.well-known/security.txt}
apix.verification.http-timeout-ms=${APIX_VERIFICATION_TIMEOUT_MS:5000}
apix.org.tan.expose-in-response=false
# ── Mail signing (Ed25519) ────────────────────────────────────────────────────
# PKCS#8 private key and SubjectPublicKeyInfo public key, Base64 standard encoding.
# If blank, an ephemeral key pair is generated at startup (dev/test only).
apix.mail.signing.private-key-base64=${APIX_MAIL_SIGNING_PRIVATE_KEY:}
apix.mail.signing.public-key-base64=${APIX_MAIL_SIGNING_PUBLIC_KEY:}
# Key ID published in signed payloads and the /mail-signing-keys endpoint.
# Convention: YYYY-MM, rotated every 6 months.
apix.mail.signing.kid=${APIX_MAIL_SIGNING_KID:dev}
apix.sanctions.cache-path=${SANCTIONS_CACHE_PATH:./sanctions-cache}
# ── Logging ───────────────────────────────────────────────────────────────────
@@ -33,3 +47,30 @@ quarkus.log.console.json=false
# ── Health ────────────────────────────────────────────────────────────────────
quarkus.smallrye-health.root-path=/q/health
# ── Observability ─────────────────────────────────────────────────────────────
# HTTP server request metrics (http_server_requests_seconds histogram) are
# auto-instrumented by quarkus-micrometer. Prometheus scrapes /q/metrics.
quarkus.micrometer.enabled=true
quarkus.micrometer.export.prometheus.enabled=true
# Without match-patterns, the uri label is the raw request path and UUIDs create
# unbounded cardinality. Order matters: specific sub-paths before the catch-all.
quarkus.micrometer.binder.http-server.match-patterns=\
/services/[0-9a-f-]+/replacements=/services/{id}/replacements,\
/services/[0-9a-f-]+/history=/services/{id}/history,\
/services/[0-9a-f-]+/olevel=/services/{id}/olevel,\
/services/[0-9a-f-]+=/services/{id},\
/organizations/[0-9a-f-]+/verify=/organizations/{id}/verify,\
/organizations/[0-9a-f-]+/request-upgrade=/organizations/{id}/request-upgrade,\
/organizations/[0-9a-f-]+/earned-level=/organizations/{id}/earned-level,\
/organizations/[0-9a-f-]+/temp-grant=/organizations/{id}/temp-grant,\
/organizations/[0-9a-f-]+/rotate-key=/organizations/{id}/rotate-key,\
/organizations/[0-9a-f-]+/request-tan=/organizations/{id}/request-tan,\
/organizations/[0-9a-f-]+/rotate-key-with-tan=/organizations/{id}/rotate-key-with-tan,\
/organizations/[0-9a-f-]+/rotate-key-dns=/organizations/{id}/rotate-key-dns,\
/organizations/[0-9a-f-]+/confirm-key-rotation-dns=/organizations/{id}/confirm-key-rotation-dns,\
/organizations/[0-9a-f-]+/notify-key-rotation-fraud=/organizations/{id}/notify-key-rotation-fraud,\
/organizations/[0-9a-f-]+/fraud-lock=/organizations/{id}/fraud-lock,\
/organizations/[0-9a-f-]+/audit-log=/organizations/{id}/audit-log,\
/organizations/[0-9a-f-]+/event-feed=/organizations/{id}/event-feed,\
/organizations/[0-9a-f-]+=/organizations/{id}
@@ -0,0 +1,122 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
<changeSet id="010" author="apix">
<createTable tableName="organizations">
<column name="id" type="uuid" defaultValueComputed="gen_random_uuid()">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="registrant_name" type="varchar(255)">
<constraints nullable="false"/>
</column>
<column name="registrant_email" type="varchar(255)">
<constraints nullable="false"/>
</column>
<column name="registrant_jurisdiction" type="varchar(10)">
<constraints nullable="false"/>
</column>
<column name="registrant_org_type" type="varchar(50)">
<constraints nullable="false"/>
</column>
<!-- Domain used for DNS and hygiene verification -->
<column name="domain" type="varchar(255)">
<constraints nullable="false"/>
</column>
<!-- GLEIF LEI — populated during O-2 if a GLEIF record is found -->
<column name="lei" type="varchar(20)"/>
<!-- Credentials — stored as SHA-256 hex hashes, never in plaintext -->
<column name="api_key_hash" type="varchar(64)">
<constraints nullable="false" unique="true" uniqueConstraintName="uq_org_api_key_hash"/>
</column>
<!-- Rotation secret exposure → always triggers admin+TAN process -->
<column name="rotation_secret_hash" type="varchar(64)">
<constraints nullable="false" unique="true" uniqueConstraintName="uq_org_rotation_secret_hash"/>
</column>
<!-- O-level state machine -->
<column name="target_o_level" type="varchar(50)">
<constraints nullable="false"/>
</column>
<column name="earned_o_level" type="varchar(50)" defaultValue="UNVERIFIED">
<constraints nullable="false"/>
</column>
<!-- PENDING | VERIFYING | ACHIEVED | FAILED | MANUAL_REVIEW -->
<column name="verification_status" type="varchar(50)" defaultValue="PENDING">
<constraints nullable="false"/>
</column>
<!-- Which step is in progress or last failed -->
<column name="verification_step" type="varchar(100)"/>
<column name="verification_error" type="text"/>
<!-- Random token org owner must publish as DNS TXT _apix-verification.{domain} -->
<column name="dns_verification_token" type="varchar(64)"/>
<!-- BSF temporary elevated grant -->
<column name="temp_max_o_level" type="varchar(50)"/>
<column name="temp_max_expires_at" type="timestamptz"/>
<column name="temp_max_granted_by" type="varchar(255)"/>
<column name="temp_max_granted_reason" type="text"/>
<!-- Emergency key rotation via admin TAN (one active TAN per org) -->
<column name="pending_tan_hash" type="varchar(64)"/>
<column name="pending_tan_expires_at" type="timestamptz"/>
<!-- Rate-limit: max 3 TAN requests per 24h -->
<column name="tan_request_count_24h" type="integer" defaultValueNumeric="0">
<constraints nullable="false"/>
</column>
<column name="tan_last_requested_at" type="timestamptz"/>
<!-- Fraud lock: set by /notify-key-rotation-fraud; blocks all rotation until cleared by BSF admin -->
<column name="fraud_locked" type="boolean" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
<column name="fraud_locked_at" type="timestamptz"/>
<column name="created_at" type="timestamptz" defaultValueComputed="now()">
<constraints nullable="false"/>
</column>
<column name="updated_at" type="timestamptz" defaultValueComputed="now()">
<constraints nullable="false"/>
</column>
</createTable>
<!-- Append-only audit trail of every O-level change event -->
<createTable tableName="org_verification_events">
<column name="id" type="uuid" defaultValueComputed="gen_random_uuid()">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="organization_id" type="uuid">
<constraints nullable="false"
foreignKeyName="fk_org_event_org"
references="organizations(id)"
deleteCascade="true"/>
</column>
<!-- REGISTERED | LEVEL_EARNED | UPGRADE_REQUESTED | VERIFICATION_FAILED |
TEMP_GRANTED | TEMP_REVOKED | TEMP_EXPIRED | LEVEL_REVOKED |
KEY_ROTATED | TAN_ISSUED | DNS_ROTATION_INITIATED |
FRAUD_REPORTED | FRAUD_LOCK_CLEARED -->
<column name="event_type" type="varchar(50)">
<constraints nullable="false"/>
</column>
<column name="from_level" type="varchar(50)"/>
<column name="to_level" type="varchar(50)"/>
<!-- SYSTEM | OWNER | BSF_ADMIN -->
<column name="triggered_by" type="varchar(50)"/>
<column name="notes" type="text"/>
<column name="created_at" type="timestamptz" defaultValueComputed="now()">
<constraints nullable="false"/>
</column>
</createTable>
<createIndex tableName="org_verification_events" indexName="idx_org_events_org_id">
<column name="organization_id"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
<changeSet id="011" author="apix">
<!-- DNS-challenge key rotation fields (bot-friendly, ACME DNS-01 analogy).
Token is stored plaintext — it is a public DNS value, not a secret. -->
<addColumn tableName="organizations">
<column name="pending_rotation_challenge_token" type="varchar(64)"/>
<column name="pending_rotation_challenge_expires_at" type="timestamptz"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -14,5 +14,7 @@
<include file="changes/007-service-replacements.xml" relativeToChangelogFile="true"/>
<include file="changes/008-sunset-at.xml" relativeToChangelogFile="true"/>
<include file="changes/009-iot-profiles.xml" relativeToChangelogFile="true"/>
<include file="changes/010-organizations.xml" relativeToChangelogFile="true"/>
<include file="changes/011-org-rotation-challenge.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>