From 35d010cf263bbaf60a3c1d9f70af8daa9cd51b18 Mon Sep 17 00:00:00 2001 From: Jason Doyle Date: Thu, 3 Sep 2026 17:53:37 -0700 Subject: [PATCH 1/2] Add validated multi-cloud deployments --- .env.example | 6 +- .github/workflows/ci.yml | 9 + .gitignore | 5 + CHANGELOG.md | 4 + README.md | 16 + deploy/CONTRACT.md | 127 +++++ deploy/README.md | 38 ++ deploy/aws/.terraform.lock.hcl | 25 + deploy/aws/README.md | 96 ++++ deploy/aws/main.tf | 335 +++++++++++++ deploy/aws/outputs.tf | 20 + deploy/aws/terraform.tfvars.example | 26 + deploy/aws/variables.tf | 163 +++++++ deploy/aws/versions.tf | 14 + deploy/azure/README.md | 108 ++++ deploy/azure/deploy.ps1 | 31 ++ deploy/azure/main.bicep | 460 ++++++++++++++++++ deploy/azure/main.example.bicepparam | 21 + deploy/gcp/.terraform.lock.hcl | 43 ++ deploy/gcp/README.md | 56 +++ deploy/gcp/main.tf | 150 ++++++ deploy/gcp/outputs.tf | 12 + deploy/gcp/terraform.tfvars.example | 27 + deploy/gcp/variables.tf | 164 +++++++ deploy/gcp/versions.tf | 14 + .../helm/agentic-data-kernel/Chart.yaml | 6 + .../helm/agentic-data-kernel/README.md | 94 ++++ .../agentic-data-kernel/templates/NOTES.txt | 19 + .../templates/_helpers.tpl | 48 ++ .../templates/api-deployment.yaml | 180 +++++++ .../templates/bootstrap-job.yaml | 107 ++++ .../templates/configmap.yaml | 29 ++ .../templates/ingress.yaml | 36 ++ .../templates/migrate-job.yaml | 108 ++++ .../templates/networkpolicy.yaml | 29 ++ .../agentic-data-kernel/templates/pdb.yaml | 14 + .../agentic-data-kernel/templates/pvc.yaml | 21 + .../templates/service.yaml | 20 + .../templates/serviceaccount.yaml | 13 + .../templates/worker-deployment.yaml | 160 ++++++ .../agentic-data-kernel/values.schema.json | 182 +++++++ .../helm/agentic-data-kernel/values.yaml | 129 +++++ docker-compose.yml | 6 + docs/PRODUCTION.md | 36 ++ package.json | 4 + scripts/test-package.mjs | 15 + scripts/validate-deployments.ps1 | 143 ++++++ src/production/bootstrap.ts | 298 ++++++++++++ src/production/cli.ts | 10 + src/production/config.ts | 36 ++ src/production/database.ts | 48 +- src/production/index.ts | 2 + src/production/migrations.ts | 50 ++ src/test/production.test.ts | 429 ++++++++++++++++ 54 files changed, 4239 insertions(+), 3 deletions(-) create mode 100644 deploy/CONTRACT.md create mode 100644 deploy/README.md create mode 100644 deploy/aws/.terraform.lock.hcl create mode 100644 deploy/aws/README.md create mode 100644 deploy/aws/main.tf create mode 100644 deploy/aws/outputs.tf create mode 100644 deploy/aws/terraform.tfvars.example create mode 100644 deploy/aws/variables.tf create mode 100644 deploy/aws/versions.tf create mode 100644 deploy/azure/README.md create mode 100644 deploy/azure/deploy.ps1 create mode 100644 deploy/azure/main.bicep create mode 100644 deploy/azure/main.example.bicepparam create mode 100644 deploy/gcp/.terraform.lock.hcl create mode 100644 deploy/gcp/README.md create mode 100644 deploy/gcp/main.tf create mode 100644 deploy/gcp/outputs.tf create mode 100644 deploy/gcp/terraform.tfvars.example create mode 100644 deploy/gcp/variables.tf create mode 100644 deploy/gcp/versions.tf create mode 100644 deploy/kubernetes/helm/agentic-data-kernel/Chart.yaml create mode 100644 deploy/kubernetes/helm/agentic-data-kernel/README.md create mode 100644 deploy/kubernetes/helm/agentic-data-kernel/templates/NOTES.txt create mode 100644 deploy/kubernetes/helm/agentic-data-kernel/templates/_helpers.tpl create mode 100644 deploy/kubernetes/helm/agentic-data-kernel/templates/api-deployment.yaml create mode 100644 deploy/kubernetes/helm/agentic-data-kernel/templates/bootstrap-job.yaml create mode 100644 deploy/kubernetes/helm/agentic-data-kernel/templates/configmap.yaml create mode 100644 deploy/kubernetes/helm/agentic-data-kernel/templates/ingress.yaml create mode 100644 deploy/kubernetes/helm/agentic-data-kernel/templates/migrate-job.yaml create mode 100644 deploy/kubernetes/helm/agentic-data-kernel/templates/networkpolicy.yaml create mode 100644 deploy/kubernetes/helm/agentic-data-kernel/templates/pdb.yaml create mode 100644 deploy/kubernetes/helm/agentic-data-kernel/templates/pvc.yaml create mode 100644 deploy/kubernetes/helm/agentic-data-kernel/templates/service.yaml create mode 100644 deploy/kubernetes/helm/agentic-data-kernel/templates/serviceaccount.yaml create mode 100644 deploy/kubernetes/helm/agentic-data-kernel/templates/worker-deployment.yaml create mode 100644 deploy/kubernetes/helm/agentic-data-kernel/values.schema.json create mode 100644 deploy/kubernetes/helm/agentic-data-kernel/values.yaml create mode 100644 scripts/validate-deployments.ps1 create mode 100644 src/production/bootstrap.ts diff --git a/.env.example b/.env.example index 8c9b217..f9cc6f5 100644 --- a/.env.example +++ b/.env.example @@ -2,9 +2,13 @@ POSTGRES_PASSWORD=replace-with-a-long-random-password APP_DATABASE_PASSWORD=replace-with-a-different-long-random-password POSTGRES_PORT=54329 POSTGRES_BIND_ADDRESS=127.0.0.1 -AGENTIC_DATA_IMAGE=ghcr.io/jason-doyle/agentic-data-kernel:0.3.0-alpha.2 +AGENTIC_DATA_IMAGE=ghcr.io/jason-doyle/agentic-data-kernel:0.3.0-alpha.4 DATABASE_URL=postgresql://agentic_app:replace-with-a-different-long-random-password@127.0.0.1:54329/agentic_data MIGRATION_DATABASE_URL=postgresql://postgres:replace-with-a-long-random-password@127.0.0.1:54329/agentic_data +DATABASE_SSL=disable + +# Optional: base64-encoded PEM trust bundle for managed PostgreSQL. +# DATABASE_CA_CERT_BASE64= # At least 32 random characters. Used only to hash high-entropy API tokens. AUTH_PEPPER=replace-with-at-least-32-random-characters diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5574c5d..e0d1d58 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,6 +47,15 @@ jobs: - name: Check production CLI run: docker run --rm agentic-data-kernel:test node dist/production/cli.js --help + deployments: + name: Deployment templates + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - name: Validate Helm, Bicep, and OpenTofu + shell: pwsh + run: ./scripts/validate-deployments.ps1 + postgres: name: PostgreSQL integration runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index c8df5a1..554d498 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,8 @@ dist/ *.db-wal *.log .DS_Store +**/.terraform/ +*.tfstate +*.tfstate.* +*.tfvars +!*.tfvars.example diff --git a/CHANGELOG.md b/CHANGELOG.md index 2faed44..5f75ab5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- Added validated Helm, Azure Bicep, AWS OpenTofu, and GCP OpenTofu deployment + templates with one shared security and runtime contract. +- Added an idempotent `bootstrap-role` production command for cloud migration + workflows. - Rebuilt the README around the agent-first thesis, SRE proof, causal trace, comparative evidence, measured costs, and explicit fit boundaries. diff --git a/README.md b/README.md index c8f3c43..6fcf6db 100644 --- a/README.md +++ b/README.md @@ -322,6 +322,22 @@ agent-facing data model, invariants, and interfaces. See [Production Profile](docs/PRODUCTION.md) and [Threat Model](docs/THREAT_MODEL.md). +### Cloud deployment templates + +The package includes validated reference workloads for: + +| Platform | Template | +| --- | --- | +| Kubernetes | Helm | +| Azure Container Apps | Bicep | +| AWS ECS Fargate | OpenTofu | +| Google Kubernetes Engine | OpenTofu plus Helm | + +The templates require existing private PostgreSQL, secret stores, network +controls, TLS, and shared filesystems. They intentionally do not place +generated credentials in Bicep parameters or OpenTofu state. See +[Deployment Templates](deploy/README.md). + ## Architecture ```text diff --git a/deploy/CONTRACT.md b/deploy/CONTRACT.md new file mode 100644 index 0000000..e23ae8a --- /dev/null +++ b/deploy/CONTRACT.md @@ -0,0 +1,127 @@ +# Deployment Contract + +Every deployment template must preserve the same runtime and security +invariants. + +## Workloads + +Run four independent workloads from the same immutable image: + +| Workload | Command | Lifecycle | +| --- | --- | --- | +| Runtime role bootstrap | `node dist/production/cli.js bootstrap-role` | One shot before migrations | +| Database migration | `node dist/production/cli.js migrate` | One shot before API and worker rollout | +| API | `node dist/production/cli.js serve` | Long running, port 4318 | +| Effect worker | `node dist/production/cli.js worker` | Long running, no ingress | + +The bootstrap and migration workloads are idempotent. Migrations use a +PostgreSQL advisory lock and checksum every applied migration. + +## PostgreSQL + +The database must provide: + +- PostgreSQL 18; +- pgvector 0.8 or newer; +- `pgcrypto`; +- an administrative migration identity with `CREATEROLE` and permission to + install the required extensions; +- a fixed `agentic_app` login created by `bootstrap-role`; +- encrypted connections and private network access; +- backups and point-in-time recovery appropriate to the environment. + +The runtime role is always configured as: + +```text +LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT NOBYPASSRLS +``` + +When `agentic_app` already exists, a non-superuser bootstrap identity must also +hold `ADMIN OPTION` on that role. A managed-service administrator or +superuser-equivalent identity is the simplest bootstrap and migration +credential. + +## Secrets + +Cloud secret stores or externally managed Kubernetes Secrets must provide: + +| Name | Consumer | +| --- | --- | +| `DATABASE_URL` | API and worker, using `agentic_app` | +| `MIGRATION_DATABASE_URL` | Bootstrap and migration, using the administrative identity | +| `APP_DATABASE_PASSWORD` | Bootstrap job | +| `DATABASE_CA_CERT_BASE64` | Optional PEM trust bundle for managed PostgreSQL | +| `AUTH_PEPPER` | API and worker | +| `ARTIFACT_KEYRING` | API and worker | +| `EMBEDDING_API_KEY` | API and worker | + +API and worker identities must not be able to read +`MIGRATION_DATABASE_URL` or `APP_DATABASE_PASSWORD`. Use separate runtime and +administrative secrets and cloud identities. + +If a non-superuser bootstrap identity manages `agentic_app`, its PostgreSQL 18 +membership must use `ADMIN TRUE, INHERIT FALSE, SET FALSE`. Privilege-bearing +memberships are rejected. + +`APP_DATABASE_PASSWORD` must contain 16 to 256 printable ASCII characters +without spaces. The bootstrap command derives a SCRAM-SHA-256 verifier +client-side so the plaintext password is not sent as a SQL bind value. + +`ARTIFACT_CURRENT_KEY_ID` and `ARTIFACT_KEYRING` must identify a 32-byte +base64-encoded encryption key. Secret values must not be committed to values +files, Bicep parameter files, OpenTofu variables, logs, or outputs. +Every template exposes the current key ID separately so rotations can add a +new key to the keyring before switching new writes. + +Set `DATABASE_SSL=require`. When the managed PostgreSQL certificate chain is +not present in the container's system trust store, provide its PEM CA bundle +as base64 through `DATABASE_CA_CERT_BASE64`. +Do not add `sslmode`, `sslrootcert`, or other SSL query parameters to database +URLs; the runtime rejects URL-level SSL settings so they cannot weaken the +configured verification policy. + +An authenticated database proxy running in the same pod is the exception. In +that profile, the application may use `DATABASE_SSL=disable` only for a +loopback connection while the proxy performs encrypted, authenticated +upstream transport. + +## Artifact filesystem + +The encrypted artifact store uses: + +- exclusive temporary-file creation; +- file `fsync`; +- atomic hard-link creation; +- concurrent reads and writes; +- recursive listing and deletion. + +All API and worker replicas must mount the same filesystem at +`ARTIFACT_DIR`, normally `/var/lib/agentic-data/artifacts`. The filesystem must +support hard links within one mount and be writable by UID and GID `10001`. + +Compatible examples include Azure Files NFS, Amazon EFS, Google Cloud +Filestore, and suitable Kubernetes ReadWriteMany volumes. Object-storage FUSE +drivers are not supported unless they document equivalent hard-link and +durability semantics. + +## Networking + +- Expose only the API. +- Terminate TLS at the managed ingress or load balancer. +- Keep PostgreSQL and artifact storage private. +- Set `HOST=0.0.0.0` and `PORT=4318` for the API container. +- Apply platform-specific egress controls for PostgreSQL, DNS, the embedding + endpoint, and effect destinations. Standard Kubernetes NetworkPolicy cannot + filter HTTPS by hostname. +- Configure `EFFECT_ALLOWED_HOSTS` explicitly. + +## Health and rollout + +- Liveness: `GET /health/live` +- Readiness: `GET /health/ready` +- Run bootstrap and migrations before increasing API or worker replicas. +- Use immutable image tags or digests. +- Restart workloads after rotating environment-injected secrets. + +The API rate limiter is process-local. Horizontal replicas multiply the +effective aggregate request allowance. diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..ce99e02 --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,38 @@ +# Deployment Templates + +These templates deploy the same production workload contract across multiple +platforms: + +| Platform | Template | Scope | +| --- | --- | --- | +| Kubernetes | [Helm chart](kubernetes/helm/agentic-data-kernel) | Portable API, worker, bootstrap, migration, service, ingress, and artifact PVC | +| Azure | [Bicep](azure) | Azure Container Apps workloads and manual jobs | +| AWS | [OpenTofu](aws) | ECS Fargate services and one-shot task definitions | +| Google Cloud | [OpenTofu](gcp) | Helm deployment into an existing GKE cluster | + +Read [CONTRACT.md](CONTRACT.md) before using any template. + +Every template requires an explicit immutable application image version or +digest. No runnable default points at an older release. + +The templates intentionally consume existing cloud networks, PostgreSQL +servers, secret stores, and persistent storage. Landing zones and credentials +vary substantially between organizations, and placing generated database or +provider credentials in Bicep parameters or OpenTofu state would create an +unsafe default. + +## Support level + +The templates are reference deployments for the repository's bounded +single-primary production profile. Static validation runs in CI. Cloud applies +require an account, billable resources, provider-specific policy decisions, +and operator verification. + +They do not claim: + +- multi-region database failover; +- zero-downtime schema changes; +- automatic secret rotation; +- cloud object-storage support; +- compatibility with filesystems that lack POSIX hard links; +- a complete organizational landing zone. diff --git a/deploy/aws/.terraform.lock.hcl b/deploy/aws/.terraform.lock.hcl new file mode 100644 index 0000000..a8df396 --- /dev/null +++ b/deploy/aws/.terraform.lock.hcl @@ -0,0 +1,25 @@ +# This file is maintained automatically by "tofu init". +# Manual edits may be lost in future updates. + +provider "registry.opentofu.org/hashicorp/aws" { + version = "6.63.0" + constraints = "~> 6.0" + hashes = [ + "h1:AMRlrrM3z1SmrslOtotqKq02zapxLKtXaSN9Jbs0Oho=", + "zh:039a03e920e55f14a691feb67216a2d142bfee603128e15f9c5138f9ecd85016", + "zh:14e060b7f46ca7b0fa009b91aef419c58cbdff854de96e9a1d853166f8d902fd", + "zh:18803e8fe2c291c8db5526c71b3287ff7c81453f10ca6d8e69cdf9c535b00783", + "zh:1b83fce6e31a6095e932d80a7c3f47ac04252653a2de2b98ec6204563310fcba", + "zh:2add7bc976ceebb1a94d84598762c9b9cf281ca52ec83deeb4e95e90aa200a12", + "zh:2f22cd5372408f11937fa5513a7b960d3cebc334c5ec65fc5322c3bac1c1f664", + "zh:41c5e857dacfd83b7ca12a435204957ff6ca8830b9efefd0d381ad4d63b19779", + "zh:4eace6246e46999782d219bc4f50f83d19ef9156bacf5ca1528da12da4918015", + "zh:5e1c1281c3f929399e2ed3dbdce03426fd57a9ec55cd36e04acf1712aa5954ba", + "zh:608272b1f5d75ead123c9d933aa1fed7dc832cedd1506019046b4c8fdcc91dce", + "zh:6b3680f8a2f7be2c171953aba89d639fb2624b9cf52ec304e16434874566601d", + "zh:99aa1006f2141f3341a02020e1c91abfb02280e57c77e0415c98b8d900353d88", + "zh:9ad235bef34a89a8dd9943f9fa9f05cc729bb52a4e0dc926a31bb13cb0ae2418", + "zh:e0e3ac361e04748a4ca0c1cdbb6abab2aa817f4ad67e1692817d16e370161d59", + "zh:f60962c982a41fde956e796425e7194b4311741c179c060c1c8b5e16a557d635", + ] +} diff --git a/deploy/aws/README.md b/deploy/aws/README.md new file mode 100644 index 0000000..129ff61 --- /dev/null +++ b/deploy/aws/README.md @@ -0,0 +1,96 @@ +# AWS ECS Fargate with OpenTofu + +This module deploys Agentic Data Kernel task definitions and services into an +existing AWS landing zone. + +## Required AWS resources + +- ECS cluster; +- at least two private subnets; +- security groups allowing ALB to API port 4318, tasks to PostgreSQL 5432, + tasks to EFS 2049, and required HTTPS egress; +- an ALB HTTPS listener and IP target group whose health check uses + `/health/ready`; +- RDS PostgreSQL 18 with pgvector 0.8+ and pgcrypto; +- encrypted EFS with mount targets and an access point enforcing UID/GID + `10001`; +- task execution and task IAM roles; +- Secrets Manager values matching the deployment contract. + +The execution role must read every supplied secret. The task role must be +allowed to mount the EFS access point with transit encryption. +Private subnets need NAT or the appropriate VPC endpoints to pull the image, +write logs, and read Secrets Manager. + +Reference the RDS CA bundle through `secret_arns.database_ca_cert` unless it is +already present in the container's system trust store. + +## Deploy + +```powershell +Copy-Item .\deploy\aws\terraform.tfvars.example ` + .\deploy\aws\terraform.tfvars + +# Uncomment and set every required value, including an immutable image. +tofu -chdir=deploy\aws init +tofu -chdir=deploy\aws apply +``` + +The initial apply creates services with desired count zero. Run bootstrap and +migration tasks in order: + +```powershell +$network = tofu -chdir=deploy\aws output -raw network_configuration_json +$bootstrap = tofu -chdir=deploy\aws output -raw bootstrap_task_definition_arn +$migrate = tofu -chdir=deploy\aws output -raw migrate_task_definition_arn +$clusterArn = Read-Host "Existing ECS cluster ARN" +$network | Set-Content -NoNewline .\agentic-network.json + +function Invoke-AgenticEcsTask { + param([string]$TaskDefinition) + + $taskArn = aws ecs run-task ` + --cluster $clusterArn ` + --launch-type FARGATE ` + --platform-version 1.4.0 ` + --task-definition $TaskDefinition ` + --network-configuration file://agentic-network.json ` + --query "tasks[0].taskArn" ` + --output text + if ($LASTEXITCODE -ne 0 -or -not $taskArn -or $taskArn -eq "None") { + throw "Failed to start $TaskDefinition" + } + + aws ecs wait tasks-stopped ` + --cluster $clusterArn ` + --tasks $taskArn + if ($LASTEXITCODE -ne 0) { + throw "Failed while waiting for $taskArn" + } + + $exitCode = aws ecs describe-tasks ` + --cluster $clusterArn ` + --tasks $taskArn ` + --query "tasks[0].containers[0].exitCode" ` + --output text + if ($exitCode -ne "0") { + throw "$TaskDefinition exited with code $exitCode" + } +} + +Invoke-AgenticEcsTask $bootstrap +Invoke-AgenticEcsTask $migrate +``` + +Then set `services_enabled = true` and apply again. + +Use the same two-phase sequence for every image upgrade: + +1. apply the new immutable `image` with `services_enabled = false`; +2. run bootstrap and migration to exit code zero; +3. apply unchanged inputs with `services_enabled = true`. + +Do not update the image while leaving `services_enabled = true`. + +Secrets are referenced by ARN and are not created by this module. Do not pass +secret values through `.tfvars`. diff --git a/deploy/aws/main.tf b/deploy/aws/main.tf new file mode 100644 index 0000000..89bcb93 --- /dev/null +++ b/deploy/aws/main.tf @@ -0,0 +1,335 @@ +locals { + artifact_mount_path = "/var/lib/agentic-data/artifacts" + common_environment = [ + { name = "DATABASE_SSL", value = "require" }, + { name = "DATABASE_POOL_SIZE", value = "10" }, + { name = "DATABASE_STATEMENT_TIMEOUT_MS", value = "30000" }, + { name = "ARTIFACT_CURRENT_KEY_ID", value = var.artifact_current_key_id }, + { name = "ARTIFACT_DIR", value = local.artifact_mount_path }, + { name = "EMBEDDING_BASE_URL", value = var.embedding_base_url }, + { name = "EMBEDDING_MODEL", value = var.embedding_model }, + { name = "EMBEDDING_VERSION", value = var.embedding_version }, + { name = "EMBEDDING_DIMENSIONS", value = tostring(var.embedding_dimensions) }, + { name = "EFFECT_ALLOWED_HOSTS", value = var.effect_allowed_hosts }, + { name = "HOST", value = "0.0.0.0" }, + { name = "PORT", value = "4318" }, + { name = "LOG_LEVEL", value = "info" } + ] + runtime_secrets = [ + { name = "DATABASE_URL", valueFrom = var.secret_arns.database_url }, + { name = "AUTH_PEPPER", valueFrom = var.secret_arns.auth_pepper }, + { name = "ARTIFACT_KEYRING", valueFrom = var.secret_arns.artifact_keyring }, + { name = "EMBEDDING_API_KEY", valueFrom = var.secret_arns.embedding_api_key } + ] + database_ca_secret = var.secret_arns.database_ca_cert == null ? [] : [ + { + name = "DATABASE_CA_CERT_BASE64" + valueFrom = var.secret_arns.database_ca_cert + } + ] + network_configuration = { + awsvpcConfiguration = { + subnets = var.private_subnet_ids + securityGroups = var.task_security_group_ids + assignPublicIp = "DISABLED" + } + } +} + +resource "aws_cloudwatch_log_group" "api" { + name = "/ecs/${var.name_prefix}/api" + retention_in_days = var.log_retention_days + tags = var.tags +} + +resource "aws_cloudwatch_log_group" "worker" { + name = "/ecs/${var.name_prefix}/worker" + retention_in_days = var.log_retention_days + tags = var.tags +} + +resource "aws_cloudwatch_log_group" "jobs" { + name = "/ecs/${var.name_prefix}/jobs" + retention_in_days = var.log_retention_days + tags = var.tags +} + +resource "aws_ecs_task_definition" "api" { + family = "${var.name_prefix}-api" + requires_compatibilities = ["FARGATE"] + network_mode = "awsvpc" + cpu = tostring(var.api_cpu) + memory = tostring(var.api_memory) + execution_role_arn = var.execution_role_arn + task_role_arn = var.task_role_arn + + runtime_platform { + operating_system_family = "LINUX" + cpu_architecture = "X86_64" + } + + volume { + name = "artifacts" + + efs_volume_configuration { + file_system_id = var.efs_file_system_id + root_directory = "/" + transit_encryption = "ENABLED" + + authorization_config { + access_point_id = var.efs_access_point_id + iam = "ENABLED" + } + } + } + + container_definitions = jsonencode([ + { + name = "api" + image = var.image + essential = true + user = "10001:10001" + readonlyRootFilesystem = true + command = ["node", "dist/production/cli.js", "serve"] + environment = local.common_environment + secrets = concat(local.runtime_secrets, local.database_ca_secret) + portMappings = [ + { + name = "http" + containerPort = 4318 + hostPort = 4318 + protocol = "tcp" + appProtocol = "http" + } + ] + mountPoints = [ + { + sourceVolume = "artifacts" + containerPath = local.artifact_mount_path + readOnly = false + } + ] + healthCheck = { + command = [ + "CMD-SHELL", + "node -e \"fetch('http://127.0.0.1:4318/health/live').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\"" + ] + interval = 30 + timeout = 5 + retries = 3 + startPeriod = 30 + } + logConfiguration = { + logDriver = "awslogs" + options = { + awslogs-group = aws_cloudwatch_log_group.api.name + awslogs-region = var.aws_region + awslogs-stream-prefix = "api" + } + } + } + ]) + + tags = var.tags +} + +resource "aws_ecs_task_definition" "worker" { + family = "${var.name_prefix}-worker" + requires_compatibilities = ["FARGATE"] + network_mode = "awsvpc" + cpu = tostring(var.worker_cpu) + memory = tostring(var.worker_memory) + execution_role_arn = var.execution_role_arn + task_role_arn = var.task_role_arn + + runtime_platform { + operating_system_family = "LINUX" + cpu_architecture = "X86_64" + } + + volume { + name = "artifacts" + + efs_volume_configuration { + file_system_id = var.efs_file_system_id + root_directory = "/" + transit_encryption = "ENABLED" + + authorization_config { + access_point_id = var.efs_access_point_id + iam = "ENABLED" + } + } + } + + container_definitions = jsonencode([ + { + name = "worker" + image = var.image + essential = true + user = "10001:10001" + readonlyRootFilesystem = true + command = ["node", "dist/production/cli.js", "worker"] + environment = local.common_environment + secrets = concat(local.runtime_secrets, local.database_ca_secret) + mountPoints = [ + { + sourceVolume = "artifacts" + containerPath = local.artifact_mount_path + readOnly = false + } + ] + logConfiguration = { + logDriver = "awslogs" + options = { + awslogs-group = aws_cloudwatch_log_group.worker.name + awslogs-region = var.aws_region + awslogs-stream-prefix = "worker" + } + } + } + ]) + + tags = var.tags +} + +resource "aws_ecs_task_definition" "bootstrap" { + family = "${var.name_prefix}-bootstrap" + requires_compatibilities = ["FARGATE"] + network_mode = "awsvpc" + cpu = "256" + memory = "512" + execution_role_arn = var.execution_role_arn + task_role_arn = var.task_role_arn + + container_definitions = jsonencode([ + { + name = "bootstrap" + image = var.image + essential = true + user = "10001:10001" + readonlyRootFilesystem = true + command = ["node", "dist/production/cli.js", "bootstrap-role"] + environment = [ + { name = "DATABASE_SSL", value = "require" } + ] + secrets = concat([ + { + name = "MIGRATION_DATABASE_URL" + valueFrom = var.secret_arns.migration_database_url + }, + { + name = "APP_DATABASE_PASSWORD" + valueFrom = var.secret_arns.app_database_password + } + ], local.database_ca_secret) + logConfiguration = { + logDriver = "awslogs" + options = { + awslogs-group = aws_cloudwatch_log_group.jobs.name + awslogs-region = var.aws_region + awslogs-stream-prefix = "bootstrap" + } + } + } + ]) + + tags = var.tags +} + +resource "aws_ecs_task_definition" "migrate" { + family = "${var.name_prefix}-migrate" + requires_compatibilities = ["FARGATE"] + network_mode = "awsvpc" + cpu = "256" + memory = "512" + execution_role_arn = var.execution_role_arn + task_role_arn = var.task_role_arn + + container_definitions = jsonencode([ + { + name = "migrate" + image = var.image + essential = true + user = "10001:10001" + readonlyRootFilesystem = true + command = ["node", "dist/production/cli.js", "migrate"] + environment = [ + { name = "DATABASE_SSL", value = "require" }, + { name = "EMBEDDING_MODEL", value = var.embedding_model }, + { name = "EMBEDDING_VERSION", value = var.embedding_version }, + { name = "EMBEDDING_DIMENSIONS", value = tostring(var.embedding_dimensions) } + ] + secrets = concat([ + { + name = "MIGRATION_DATABASE_URL" + valueFrom = var.secret_arns.migration_database_url + } + ], local.database_ca_secret) + logConfiguration = { + logDriver = "awslogs" + options = { + awslogs-group = aws_cloudwatch_log_group.jobs.name + awslogs-region = var.aws_region + awslogs-stream-prefix = "migrate" + } + } + } + ]) + + tags = var.tags +} + +resource "aws_ecs_service" "api" { + name = "${var.name_prefix}-api" + cluster = var.ecs_cluster_arn + task_definition = aws_ecs_task_definition.api.arn + desired_count = var.services_enabled ? var.api_desired_count : 0 + launch_type = "FARGATE" + platform_version = "1.4.0" + force_new_deployment = var.services_enabled + wait_for_steady_state = true + + deployment_circuit_breaker { + enable = true + rollback = true + } + + network_configuration { + subnets = var.private_subnet_ids + security_groups = var.task_security_group_ids + assign_public_ip = false + } + + load_balancer { + target_group_arn = var.target_group_arn + container_name = "api" + container_port = 4318 + } + + tags = var.tags +} + +resource "aws_ecs_service" "worker" { + name = "${var.name_prefix}-worker" + cluster = var.ecs_cluster_arn + task_definition = aws_ecs_task_definition.worker.arn + desired_count = var.services_enabled ? var.worker_desired_count : 0 + launch_type = "FARGATE" + platform_version = "1.4.0" + force_new_deployment = var.services_enabled + wait_for_steady_state = true + + deployment_circuit_breaker { + enable = true + rollback = true + } + + network_configuration { + subnets = var.private_subnet_ids + security_groups = var.task_security_group_ids + assign_public_ip = false + } + + tags = var.tags +} diff --git a/deploy/aws/outputs.tf b/deploy/aws/outputs.tf new file mode 100644 index 0000000..aa1d597 --- /dev/null +++ b/deploy/aws/outputs.tf @@ -0,0 +1,20 @@ +output "api_service_name" { + value = aws_ecs_service.api.name +} + +output "worker_service_name" { + value = aws_ecs_service.worker.name +} + +output "bootstrap_task_definition_arn" { + value = aws_ecs_task_definition.bootstrap.arn +} + +output "migrate_task_definition_arn" { + value = aws_ecs_task_definition.migrate.arn +} + +output "network_configuration_json" { + description = "Pass this object to aws ecs run-task for bootstrap and migration." + value = jsonencode(local.network_configuration) +} diff --git a/deploy/aws/terraform.tfvars.example b/deploy/aws/terraform.tfvars.example new file mode 100644 index 0000000..3981bbe --- /dev/null +++ b/deploy/aws/terraform.tfvars.example @@ -0,0 +1,26 @@ +# Copy this file, then uncomment and replace every example before deployment. +# aws_region = "us-east-2" +# name_prefix = "agentic-data" +# image = "ghcr.io/jason-doyle/agentic-data-kernel:0.3.0-alpha.5" +# ecs_cluster_arn = "arn:aws:ecs:..." +# private_subnet_ids = ["subnet-...", "subnet-..."] +# task_security_group_ids = ["sg-..."] +# target_group_arn = "arn:aws:elasticloadbalancing:..." +# efs_file_system_id = "fs-..." +# efs_access_point_id = "fsap-..." +# execution_role_arn = "arn:aws:iam::...:role/..." +# task_role_arn = "arn:aws:iam::...:role/..." +# embedding_base_url = "https://api.openai.com/v1" +# artifact_current_key_id = "v1" +# effect_allowed_hosts = "payments.example.com,deployments.example.com" +# services_enabled = false +# +# secret_arns = { +# database_url = "arn:aws:secretsmanager:..." +# migration_database_url = "arn:aws:secretsmanager:..." +# app_database_password = "arn:aws:secretsmanager:..." +# database_ca_cert = "arn:aws:secretsmanager:..." +# auth_pepper = "arn:aws:secretsmanager:..." +# artifact_keyring = "arn:aws:secretsmanager:..." +# embedding_api_key = "arn:aws:secretsmanager:..." +# } diff --git a/deploy/aws/variables.tf b/deploy/aws/variables.tf new file mode 100644 index 0000000..61669c1 --- /dev/null +++ b/deploy/aws/variables.tf @@ -0,0 +1,163 @@ +variable "aws_region" { + description = "AWS region containing the existing ECS and data resources." + type = string +} + +variable "name_prefix" { + description = "Prefix for ECS and CloudWatch resources." + type = string + default = "agentic-data" +} + +variable "image" { + description = "Immutable Agentic Data Kernel image tag or digest." + type = string +} + +variable "ecs_cluster_arn" { + description = "Existing ECS cluster ARN." + type = string +} + +variable "private_subnet_ids" { + description = "Private subnets used by Fargate tasks." + type = list(string) + + validation { + condition = length(var.private_subnet_ids) >= 2 + error_message = "At least two private subnets are required." + } +} + +variable "task_security_group_ids" { + description = "Security groups allowing ALB ingress, PostgreSQL, EFS, and HTTPS egress." + type = list(string) +} + +variable "target_group_arn" { + description = "Existing ALB target group ARN using IP targets and readiness health checks." + type = string +} + +variable "efs_file_system_id" { + description = "Existing encrypted EFS filesystem ID." + type = string +} + +variable "efs_access_point_id" { + description = "EFS access point enforcing UID and GID 10001." + type = string +} + +variable "execution_role_arn" { + description = "ECS task execution role with image, logs, and secret-read permissions." + type = string +} + +variable "task_role_arn" { + description = "ECS task role allowed to mount the EFS access point." + type = string +} + +variable "secret_arns" { + description = "Secrets Manager valueFrom ARNs, optionally including JSON key selectors." + type = object({ + database_url = string + migration_database_url = string + app_database_password = string + database_ca_cert = optional(string) + auth_pepper = string + artifact_keyring = string + embedding_api_key = string + }) +} + +variable "embedding_base_url" { + description = "OpenAI-compatible embedding endpoint." + type = string +} + +variable "embedding_model" { + type = string + default = "text-embedding-3-small" +} + +variable "embedding_version" { + type = string + default = "openai-compatible-v1" +} + +variable "embedding_dimensions" { + type = number + default = 1536 + + validation { + condition = ( + var.embedding_dimensions >= 1 && + var.embedding_dimensions <= 2000 + ) + error_message = "embedding_dimensions must be from 1 through 2000." + } +} + +variable "artifact_current_key_id" { + description = "Current key ID present in ARTIFACT_KEYRING." + type = string + default = "v1" +} + +variable "effect_allowed_hosts" { + description = "Comma-separated HTTPS hosts allowed for effects." + type = string + default = "" +} + +variable "services_enabled" { + description = "Set true only after bootstrap and migration tasks succeed." + type = bool + default = false +} + +variable "api_desired_count" { + type = number + default = 1 +} + +variable "worker_desired_count" { + type = number + default = 1 +} + +variable "api_cpu" { + description = "Fargate CPU units for the API task." + type = number + default = 512 +} + +variable "api_memory" { + description = "Fargate memory in MiB for the API task." + type = number + default = 1024 +} + +variable "worker_cpu" { + description = "Fargate CPU units for the worker task." + type = number + default = 512 +} + +variable "worker_memory" { + description = "Fargate memory in MiB for the worker task." + type = number + default = 1024 +} + +variable "log_retention_days" { + type = number + default = 30 +} + +variable "tags" { + type = map(string) + default = {} +} diff --git a/deploy/aws/versions.tf b/deploy/aws/versions.tf new file mode 100644 index 0000000..84d55ef --- /dev/null +++ b/deploy/aws/versions.tf @@ -0,0 +1,14 @@ +terraform { + required_version = ">= 1.9.0, < 2.0.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 6.0" + } + } +} + +provider "aws" { + region = var.aws_region +} diff --git a/deploy/azure/README.md b/deploy/azure/README.md new file mode 100644 index 0000000..a67e133 --- /dev/null +++ b/deploy/azure/README.md @@ -0,0 +1,108 @@ +# Azure Container Apps with Bicep + +This module deploys the API, worker, runtime-role bootstrap job, and migration +job into an existing Azure Container Apps environment. + +`namePrefix` must contain 2 to 21 lowercase alphanumeric or hyphen characters, +start with a letter, and end with an alphanumeric character. + +## Required Azure resources + +- an Azure Container Apps managed environment with VNet access to PostgreSQL; +- a Premium Azure Files NFS share linked to the environment under + `artifactStorageName`; +- Azure Database for PostgreSQL Flexible Server 18 with `vector` and + `pgcrypto` allowed; +- a runtime user-assigned identity that can read only `DATABASE_URL`, + `AUTH_PEPPER`, `ARTIFACT_KEYRING`, `EMBEDDING_API_KEY`, and the optional + database CA secret; +- a separate administrative job identity that can read only + `MIGRATION_DATABASE_URL`, `APP_DATABASE_PASSWORD`, and the optional database + CA secret; +- Key Vault secrets matching the deployment contract; +- private DNS and routing between Container Apps and PostgreSQL. + +Azure Files SMB and Blob FUSE are not supported because they do not provide +the required POSIX hard-link semantics. The NFS share and Container Apps +environment must use private VNet connectivity. + +## Deploy + +Copy the disabled example parameter file, then uncomment and replace every +example, including the immutable `image` release: + +```powershell +Copy-Item .\deploy\azure\main.example.bicepparam ` + .\deploy\azure\main.bicepparam + +$resourceGroup = Read-Host "Existing resource group" +$namePrefix = Read-Host "Lowercase workload prefix" +.\deploy\azure\deploy.ps1 ` + -ResourceGroup $resourceGroup ` + -NamePrefix $namePrefix +``` + +The wrapper validates Azure Container Apps naming constraints before invoking +Bicep. + +The first deployment keeps API and worker minimum replicas at zero and omits +API ingress. Run each job and wait for success before continuing: + +```powershell +function Invoke-AgenticContainerJob { + param( + [string]$ResourceGroup, + [string]$Name + ) + + $execution = az containerapp job start ` + --resource-group $ResourceGroup ` + --name $Name ` + --query name ` + --output tsv + if ($LASTEXITCODE -ne 0 -or -not $execution) { + throw "Failed to start $Name" + } + + do { + Start-Sleep -Seconds 5 + $status = az containerapp job execution show ` + --resource-group $ResourceGroup ` + --name $Name ` + --job-execution-name $execution ` + --query properties.status ` + --output tsv + if ($LASTEXITCODE -ne 0) { + throw "Failed to read $Name execution $execution" + } + } while ($status -in @("Running", "Processing")) + + if ($status -ne "Succeeded") { + throw "$Name execution $execution ended as $status" + } +} + +Invoke-AgenticContainerJob $resourceGroup "$namePrefix-bootstrap" +Invoke-AgenticContainerJob $resourceGroup "$namePrefix-migrate" +``` + +Then redeploy with `startWorkloads = true`, which starts the workloads and +enables API ingress. + +Use the same two-phase sequence for every image upgrade: + +1. deploy the new immutable `image` with `startWorkloads = false`; +2. run bootstrap and migration to successful completion; +3. redeploy unchanged inputs with `startWorkloads = true`. + +Do not update the image while leaving `startWorkloads = true`. + +The worker is intentionally fixed at one replica in this bounded +single-primary reference profile. + +Reference the PostgreSQL CA bundle through `databaseCaSecretUrl` when it is not +already present in the container's system trust store. + +Container Apps terminates TLS for its managed public hostname. Use an internal +environment plus Application Gateway or Front Door when organizational policy +requires private ingress, WAF, or centralized custom-domain TLS. diff --git a/deploy/azure/deploy.ps1 b/deploy/azure/deploy.ps1 new file mode 100644 index 0000000..58c13b7 --- /dev/null +++ b/deploy/azure/deploy.ps1 @@ -0,0 +1,31 @@ +param( + [Parameter(Mandatory)] + [string]$ResourceGroup, + + [Parameter(Mandatory)] + [ValidateLength(2, 21)] + [ValidatePattern("^(?!.*--)[a-z][a-z0-9-]*[a-z0-9]$")] + [string]$NamePrefix, + + [string]$ParametersFile = ( + Join-Path $PSScriptRoot "main.bicepparam" + ) +) + +$ErrorActionPreference = "Stop" + +if ($NamePrefix -cne $NamePrefix.ToLowerInvariant()) { + throw "NamePrefix must use lowercase characters" +} +if (-not (Test-Path -LiteralPath $ParametersFile)) { + throw "Bicep parameters file was not found: $ParametersFile" +} + +az deployment group create ` + --resource-group $ResourceGroup ` + --template-file (Join-Path $PSScriptRoot "main.bicep") ` + --parameters $ParametersFile ` + --parameters namePrefix=$NamePrefix +if ($LASTEXITCODE -ne 0) { + throw "Azure deployment failed" +} diff --git a/deploy/azure/main.bicep b/deploy/azure/main.bicep new file mode 100644 index 0000000..f53dd2e --- /dev/null +++ b/deploy/azure/main.bicep @@ -0,0 +1,460 @@ +targetScope = 'resourceGroup' + +@description('Azure region for the Container Apps workloads.') +param location string = resourceGroup().location + +@minLength(2) +@maxLength(21) +@description('Lowercase alphanumeric and hyphen prefix used for workload names.') +param namePrefix string = 'agentic-data' + +@description('Immutable Agentic Data Kernel image tag or digest.') +param image string + +@description('Existing Azure Container Apps managed environment resource ID.') +param managedEnvironmentId string + +@description('Existing managed-environment storage link backed by Azure Files NFS.') +param artifactStorageName string + +@description('Runtime identity allowed to read only API and worker secrets.') +param runtimeIdentityResourceId string + +@description('Administrative job identity allowed to read bootstrap and migration secrets.') +param adminIdentityResourceId string + +@description('Key Vault secret URL containing the agentic_app DATABASE_URL.') +param databaseUrlSecretUrl string + +@description('Key Vault secret URL containing the administrative MIGRATION_DATABASE_URL.') +param migrationDatabaseUrlSecretUrl string + +@description('Key Vault secret URL containing APP_DATABASE_PASSWORD.') +param appDatabasePasswordSecretUrl string + +@description('Optional Key Vault secret URL containing DATABASE_CA_CERT_BASE64.') +param databaseCaSecretUrl string = '' + +@description('Key Vault secret URL containing AUTH_PEPPER.') +param authPepperSecretUrl string + +@description('Key Vault secret URL containing ARTIFACT_KEYRING.') +param artifactKeyringSecretUrl string + +@description('Key Vault secret URL containing EMBEDDING_API_KEY.') +param embeddingApiKeySecretUrl string + +@description('OpenAI-compatible embeddings endpoint.') +param embeddingBaseUrl string + +param embeddingModel string = 'text-embedding-3-small' +param embeddingVersion string = 'openai-compatible-v1' +param artifactCurrentKeyId string = 'v1' + +@minValue(1) +@maxValue(2000) +param embeddingDimensions int = 1536 + +@description('Comma-separated HTTPS hosts allowed for external effects.') +param effectAllowedHosts string = '' + +@description('Start the API and worker after bootstrap and migration jobs succeed.') +param startWorkloads bool = false + +param apiMinReplicas int = 1 +param apiMaxReplicas int = 3 + +var apiName = '${namePrefix}-api' +var workerName = '${namePrefix}-worker' +var bootstrapJobName = '${namePrefix}-bootstrap' +var migrateJobName = '${namePrefix}-migrate' +var artifactMountPath = '/var/lib/agentic-data/artifacts' +var runtimeDatabaseCaSecrets = empty(databaseCaSecretUrl) ? [] : [ + { + name: 'database-ca-cert' + keyVaultUrl: databaseCaSecretUrl + identity: runtimeIdentityResourceId + } +] +var adminDatabaseCaSecrets = empty(databaseCaSecretUrl) ? [] : [ + { + name: 'database-ca-cert' + keyVaultUrl: databaseCaSecretUrl + identity: adminIdentityResourceId + } +] +var databaseCaEnvironment = empty(databaseCaSecretUrl) ? [] : [ + { + name: 'DATABASE_CA_CERT_BASE64' + secretRef: 'database-ca-cert' + } +] +var runtimeSecrets = concat([ + { + name: 'database-url' + keyVaultUrl: databaseUrlSecretUrl + identity: runtimeIdentityResourceId + } + { + name: 'auth-pepper' + keyVaultUrl: authPepperSecretUrl + identity: runtimeIdentityResourceId + } + { + name: 'artifact-keyring' + keyVaultUrl: artifactKeyringSecretUrl + identity: runtimeIdentityResourceId + } + { + name: 'embedding-api-key' + keyVaultUrl: embeddingApiKeySecretUrl + identity: runtimeIdentityResourceId + } +], runtimeDatabaseCaSecrets) +var runtimeEnvironment = concat([ + { + name: 'DATABASE_URL' + secretRef: 'database-url' + } + { + name: 'DATABASE_SSL' + value: 'require' + } + { + name: 'DATABASE_POOL_SIZE' + value: '10' + } + { + name: 'AUTH_PEPPER' + secretRef: 'auth-pepper' + } + { + name: 'ARTIFACT_KEYRING' + secretRef: 'artifact-keyring' + } + { + name: 'ARTIFACT_CURRENT_KEY_ID' + value: artifactCurrentKeyId + } + { + name: 'ARTIFACT_DIR' + value: artifactMountPath + } + { + name: 'EMBEDDING_BASE_URL' + value: embeddingBaseUrl + } + { + name: 'EMBEDDING_API_KEY' + secretRef: 'embedding-api-key' + } + { + name: 'EMBEDDING_MODEL' + value: embeddingModel + } + { + name: 'EMBEDDING_VERSION' + value: embeddingVersion + } + { + name: 'EMBEDDING_DIMENSIONS' + value: string(embeddingDimensions) + } + { + name: 'EFFECT_ALLOWED_HOSTS' + value: effectAllowedHosts + } + { + name: 'HOST' + value: '0.0.0.0' + } + { + name: 'PORT' + value: '4318' + } + { + name: 'LOG_LEVEL' + value: 'info' + } +], databaseCaEnvironment) + +resource api 'Microsoft.App/containerApps@2025-07-01' = { + name: apiName + location: location + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${runtimeIdentityResourceId}': {} + } + } + properties: { + environmentId: managedEnvironmentId + configuration: union({ + activeRevisionsMode: 'Single' + secrets: runtimeSecrets + }, startWorkloads ? { + ingress: { + external: true + allowInsecure: false + targetPort: 4318 + transport: 'http' + } + } : {}) + template: { + containers: [ + { + name: 'api' + image: image + command: [ + 'node' + 'dist/production/cli.js' + 'serve' + ] + env: runtimeEnvironment + resources: { + cpu: json('0.5') + memory: '1Gi' + } + probes: [ + { + type: 'Liveness' + httpGet: { + path: '/health/live' + port: 4318 + scheme: 'HTTP' + } + initialDelaySeconds: 20 + periodSeconds: 20 + timeoutSeconds: 3 + failureThreshold: 3 + } + { + type: 'Readiness' + httpGet: { + path: '/health/ready' + port: 4318 + scheme: 'HTTP' + } + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 6 + } + ] + volumeMounts: [ + { + volumeName: 'artifacts' + mountPath: artifactMountPath + } + ] + } + ] + volumes: [ + { + name: 'artifacts' + storageName: artifactStorageName + storageType: 'NfsAzureFile' + } + ] + scale: { + minReplicas: startWorkloads ? apiMinReplicas : 0 + maxReplicas: apiMaxReplicas + } + } + } +} + +resource worker 'Microsoft.App/containerApps@2025-07-01' = { + name: workerName + location: location + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${runtimeIdentityResourceId}': {} + } + } + properties: { + environmentId: managedEnvironmentId + configuration: { + activeRevisionsMode: 'Single' + secrets: runtimeSecrets + } + template: { + containers: [ + { + name: 'worker' + image: image + command: [ + 'node' + 'dist/production/cli.js' + 'worker' + ] + env: runtimeEnvironment + resources: { + cpu: json('0.5') + memory: '1Gi' + } + volumeMounts: [ + { + volumeName: 'artifacts' + mountPath: artifactMountPath + } + ] + } + ] + volumes: [ + { + name: 'artifacts' + storageName: artifactStorageName + storageType: 'NfsAzureFile' + } + ] + scale: { + minReplicas: startWorkloads ? 1 : 0 + maxReplicas: 1 + } + } + } +} + +resource bootstrap 'Microsoft.App/jobs@2025-07-01' = { + name: bootstrapJobName + location: location + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${adminIdentityResourceId}': {} + } + } + properties: { + environmentId: managedEnvironmentId + configuration: { + triggerType: 'Manual' + replicaTimeout: 900 + replicaRetryLimit: 2 + manualTriggerConfig: { + parallelism: 1 + replicaCompletionCount: 1 + } + secrets: concat([ + { + name: 'migration-database-url' + keyVaultUrl: migrationDatabaseUrlSecretUrl + identity: adminIdentityResourceId + } + { + name: 'app-database-password' + keyVaultUrl: appDatabasePasswordSecretUrl + identity: adminIdentityResourceId + } + ], adminDatabaseCaSecrets) + } + template: { + containers: [ + { + name: 'bootstrap' + image: image + command: [ + 'node' + 'dist/production/cli.js' + 'bootstrap-role' + ] + env: concat([ + { + name: 'MIGRATION_DATABASE_URL' + secretRef: 'migration-database-url' + } + { + name: 'APP_DATABASE_PASSWORD' + secretRef: 'app-database-password' + } + { + name: 'DATABASE_SSL' + value: 'require' + } + ], databaseCaEnvironment) + resources: { + cpu: json('0.25') + memory: '0.5Gi' + } + } + ] + } + } +} + +resource migrate 'Microsoft.App/jobs@2025-07-01' = { + name: migrateJobName + location: location + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${adminIdentityResourceId}': {} + } + } + properties: { + environmentId: managedEnvironmentId + configuration: { + triggerType: 'Manual' + replicaTimeout: 900 + replicaRetryLimit: 2 + manualTriggerConfig: { + parallelism: 1 + replicaCompletionCount: 1 + } + secrets: concat([ + { + name: 'migration-database-url' + keyVaultUrl: migrationDatabaseUrlSecretUrl + identity: adminIdentityResourceId + } + ], adminDatabaseCaSecrets) + } + template: { + containers: [ + { + name: 'migrate' + image: image + command: [ + 'node' + 'dist/production/cli.js' + 'migrate' + ] + env: concat([ + { + name: 'MIGRATION_DATABASE_URL' + secretRef: 'migration-database-url' + } + { + name: 'DATABASE_SSL' + value: 'require' + } + { + name: 'EMBEDDING_MODEL' + value: embeddingModel + } + { + name: 'EMBEDDING_VERSION' + value: embeddingVersion + } + { + name: 'EMBEDDING_DIMENSIONS' + value: string(embeddingDimensions) + } + ], databaseCaEnvironment) + resources: { + cpu: json('0.25') + memory: '0.5Gi' + } + } + ] + } + } +} + +output apiName string = api.name +output apiFqdn string = startWorkloads ? api.properties.configuration.ingress.fqdn : '' +output bootstrapJobName string = bootstrap.name +output migrateJobName string = migrate.name +output bootstrapCommand string = 'az containerapp job start --resource-group ${resourceGroup().name} --name ${bootstrap.name}' +output migrateCommand string = 'az containerapp job start --resource-group ${resourceGroup().name} --name ${migrate.name}' diff --git a/deploy/azure/main.example.bicepparam b/deploy/azure/main.example.bicepparam new file mode 100644 index 0000000..5c5316b --- /dev/null +++ b/deploy/azure/main.example.bicepparam @@ -0,0 +1,21 @@ +using './main.bicep' + +// Copy this file, then uncomment and replace every example before deployment. +// param location = 'eastus2' +// param namePrefix = 'agentic-data' +// param image = 'ghcr.io/jason-doyle/agentic-data-kernel:0.3.0-alpha.5' +// param managedEnvironmentId = '/subscriptions/.../managedEnvironments/...' +// param artifactStorageName = 'artifact-store' +// param runtimeIdentityResourceId = '/subscriptions/.../userAssignedIdentities/...' +// param adminIdentityResourceId = '/subscriptions/.../userAssignedIdentities/...' +// param databaseUrlSecretUrl = 'https://example.vault.azure.net/secrets/database-url' +// param migrationDatabaseUrlSecretUrl = 'https://example.vault.azure.net/secrets/migration-database-url' +// param appDatabasePasswordSecretUrl = 'https://example.vault.azure.net/secrets/app-database-password' +// param databaseCaSecretUrl = 'https://example.vault.azure.net/secrets/database-ca-cert' +// param authPepperSecretUrl = 'https://example.vault.azure.net/secrets/auth-pepper' +// param artifactKeyringSecretUrl = 'https://example.vault.azure.net/secrets/artifact-keyring' +// param embeddingApiKeySecretUrl = 'https://example.vault.azure.net/secrets/embedding-api-key' +// param embeddingBaseUrl = 'https://api.openai.com/v1' +// param artifactCurrentKeyId = 'v1' +// param effectAllowedHosts = 'payments.example.com,deployments.example.com' +// param startWorkloads = false diff --git a/deploy/gcp/.terraform.lock.hcl b/deploy/gcp/.terraform.lock.hcl new file mode 100644 index 0000000..ce78c32 --- /dev/null +++ b/deploy/gcp/.terraform.lock.hcl @@ -0,0 +1,43 @@ +# This file is maintained automatically by "tofu init". +# Manual edits may be lost in future updates. + +provider "registry.opentofu.org/hashicorp/google" { + version = "7.46.0" + constraints = "~> 7.0" + hashes = [ + "h1:6TAxBmA0ah3ipwW79a9ouirDo4XEe973DLR7TTzg/7c=", + "zh:0947dcaccf0c6b612a754c5079bedc3ea48af0554aa8b0cd7aa435c98903eb44", + "zh:13b83e4d9c789fddf92bf8855224922a9b4ca6d92c16b4f53d1b05420761d1cc", + "zh:1fb255c02dc0c4f34b2d76ab61985770ef1730b958c05fb3a8e4a992dd76bc81", + "zh:23d46111f3e8379c01f920377481b764004a60dd26e9aa608d30c99a30612080", + "zh:29dcc3341c609a835b80cc426a07606d4f5d7efe52bd53ba2b1e5bfcb683b61a", + "zh:4583121fe428190a246e44921273cda9de8702f00112a07a8f11ce30a3148f3d", + "zh:b7ce6b7dc759758a8cecb491174c5bd52e581b3661aae4cc88788a61928283ec", + "zh:b97ced62813f202b028b63e2e55a466ba633232f0f1e1ff2cdd0e7658d7ae5c4", + "zh:ca9db60cbe57c23e044fda2b6d69daf8d76791d1fe604284852c1bfb69bfe86e", + "zh:cf8a8d6c0dfbd2d084acaa028a45e5b56fb3655de1b0d0d34529cfde57d2a421", + "zh:d5b65e1cbbe07eb9385f59f4091e84da1513b325d12108a77ac66a4b80178880", + "zh:e5340f6193db568a59da41991c4d7359cf0c2b95c413a41920548841bc91ccc5", + "zh:eaafbceb63e60334cecab669fb24292c677afd1decadbbba256314c5107d4794", + "zh:eb1fac186a9cd5b8b29e58595fa395eead041c38fbc28a4453325fb11e69e041", + "zh:ed2e11130381c6b610d4df83241695997ecdd11b58c7dc74ed584b4e2fff46d5", + ] +} + +provider "registry.opentofu.org/hashicorp/helm" { + version = "2.17.0" + constraints = "~> 2.17" + hashes = [ + "h1:69PnHoYrrDrm7C8+8PiSvRGPI55taqL14SvQR/FGM+g=", + "zh:02690815e35131a42cb9851f63a3369c216af30ad093d05b39001d43da04b56b", + "zh:27a62f12b29926387f4d71aeeee9f7ffa0ccb81a1b6066ee895716ad050d1b7a", + "zh:2d0a5babfa73604b3fefc9dab9c87f91c77fce756c2e32b294e9f1290aed26c0", + "zh:3976400ceba6dda4636e1d297e3097e1831de5628afa534a166de98a70d1dcbe", + "zh:54440ef14f342b41d75c1aded7487bfcc3f76322b75894235b47b7e89ac4bfa4", + "zh:6512e2ab9f2fa31cbb90d9249647b5c5798f62eb1215ec44da2cdaa24e38ad25", + "zh:795f327ca0b8c5368af0ed03d5d4f6da7260692b4b3ca0bd004ed542e683464d", + "zh:ba659e1d94f224bc3f1fd34cbb9d2663e3a8e734108e5a58eb49eda84b140978", + "zh:c5c8575c4458835c2acbc3d1ed5570589b14baa2525d8fbd04295c097caf41eb", + "zh:e0877a5dac3de138e61eefa26b2f5a13305a17259779465899880f70e11314e0", + ] +} diff --git a/deploy/gcp/README.md b/deploy/gcp/README.md new file mode 100644 index 0000000..e0a73e7 --- /dev/null +++ b/deploy/gcp/README.md @@ -0,0 +1,56 @@ +# Google Kubernetes Engine with OpenTofu + +This module installs the shared Helm chart into an existing GKE cluster. +GKE Autopilot is the recommended managed runtime for this profile. + +## Required Google Cloud resources + +- private, VPC-native GKE cluster; +- Cloud SQL for PostgreSQL 18 with pgvector 0.8+ and pgcrypto; +- private IP connectivity from GKE to Cloud SQL; +- Filestore mounted through a ReadWriteMany PVC; +- separate runtime and administrative Kubernetes Secrets matching the + deployment contract; +- runtime and job Kubernetes service accounts bound through Workload Identity + to a Google service account with `roles/cloudsql.client`; +- TLS ingress configuration when public access is enabled. + +Cloud Storage FUSE is not supported because the artifact store requires POSIX +hard links. Use Filestore. + +The module runs an immutable Cloud SQL Auth Proxy sidecar in API, worker, +bootstrap, and migration pods. Set both database URLs to +`127.0.0.1:5432`; the chart disables application-level TLS only for that +loopback hop while the proxy authenticates and encrypts the Cloud SQL +connection. +The API Service is annotated for a standalone GKE NEG so GCE ingress has a +container-native backend even when automatic NEG injection is unavailable. + +Create the namespace, both Secrets, and Filestore-backed PVC before applying +this module. The required Secret keys are listed in the +[Helm chart README](../kubernetes/helm/agentic-data-kernel/README.md). +Also create `job_service_account_name` before applying because Helm +pre-install hooks run before chart-managed service accounts. Bind both that +service account and the runtime service account to +`gcp_service_account_email`. + +## Deploy + +Authenticate with Google Cloud and ensure the current identity can read the +cluster and create Kubernetes resources: + +```powershell +gcloud auth application-default login +Copy-Item .\deploy\gcp\terraform.tfvars.example ` + .\deploy\gcp\terraform.tfvars + +# Uncomment and set every required value, including image_tag. +tofu -chdir=deploy\gcp init +tofu -chdir=deploy\gcp apply +``` + +The Helm release runs the runtime-role bootstrap and migration jobs before the +API and worker rollout. +When `ingress_enabled` is true, `ingress_tls_secret_name` is required. For the +GCE ingress controller, also set `kubernetes.io/ingress.allow-http` to +`"false"`. diff --git a/deploy/gcp/main.tf b/deploy/gcp/main.tf new file mode 100644 index 0000000..a0aa818 --- /dev/null +++ b/deploy/gcp/main.tf @@ -0,0 +1,150 @@ +data "google_client_config" "current" {} + +data "google_container_cluster" "target" { + project = var.project_id + location = var.location + name = var.cluster_name +} + +provider "google" { + project = var.project_id +} + +provider "helm" { + kubernetes { + host = "https://${data.google_container_cluster.target.endpoint}" + token = data.google_client_config.current.access_token + cluster_ca_certificate = base64decode(data.google_container_cluster.target.master_auth[0].cluster_ca_certificate) + } +} + +locals { + ingress_tls = var.ingress_tls_secret_name == "" ? [] : [ + { + secretName = var.ingress_tls_secret_name + hosts = [var.ingress_host] + } + ] + chart_values = { + image = { + repository = var.image_repository + tag = var.image_tag + digest = var.image_digest + } + secretRef = { + runtimeName = var.runtime_kubernetes_secret_name + adminName = var.admin_kubernetes_secret_name + } + serviceAccount = { + create = true + name = var.service_account_name + annotations = merge( + var.service_account_annotations, + { + "iam.gke.io/gcp-service-account" = var.gcp_service_account_email + } + ) + automountServiceAccountToken = true + } + jobs = { + serviceAccountName = var.job_service_account_name + automountServiceAccountToken = true + } + config = { + databaseSsl = "disable" + artifactCurrentKeyId = var.artifact_current_key_id + embeddingBaseUrl = var.embedding_base_url + embeddingModel = var.embedding_model + embeddingVersion = var.embedding_version + embeddingDimensions = tostring(var.embedding_dimensions) + effectAllowedHosts = var.effect_allowed_hosts + } + databaseProxy = { + enabled = true + image = var.database_proxy_image + port = 5432 + args = [ + "--private-ip", + "--structured-logs", + "--address=127.0.0.1", + "--port=5432", + var.cloud_sql_instance_connection_name + ] + } + persistence = { + enabled = true + existingClaim = var.artifact_pvc_name + } + service = { + annotations = { + "cloud.google.com/neg" = jsonencode({ + ingress = true + }) + } + } + api = { + replicaCount = var.api_replica_count + } + worker = { + replicaCount = var.worker_replica_count + } + ingress = { + enabled = var.ingress_enabled + className = var.ingress_class_name + annotations = var.ingress_annotations + hosts = [ + { + host = var.ingress_host + paths = [ + { + path = "/" + pathType = "Prefix" + } + ] + } + ] + tls = local.ingress_tls + tlsOnlyAnnotation = "kubernetes.io/ingress.allow-http" + tlsOnlyValue = "false" + } + } +} + +resource "helm_release" "agentic_data_kernel" { + name = var.release_name + namespace = var.namespace + create_namespace = true + chart = "${path.module}/../kubernetes/helm/agentic-data-kernel" + timeout = 900 + atomic = true + cleanup_on_fail = true + wait = true + wait_for_jobs = true + + values = [ + yamlencode(local.chart_values) + ] + + lifecycle { + precondition { + condition = ( + !var.ingress_enabled || + ( + var.ingress_tls_secret_name != "" && + local.chart_values.ingress.tlsOnlyValue == "false" + ) + ) + error_message = "GCP ingress requires a TLS Secret and HTTP disabled." + } + precondition { + condition = ( + (var.image_tag != "" && var.image_digest == "") || + ( + var.image_tag == "" && + can(regex("^sha256:[a-f0-9]{64}$", var.image_digest)) + ) + ) + error_message = "Set exactly one of image_tag or a valid sha256 image_digest." + } + } +} diff --git a/deploy/gcp/outputs.tf b/deploy/gcp/outputs.tf new file mode 100644 index 0000000..01e42d5 --- /dev/null +++ b/deploy/gcp/outputs.tf @@ -0,0 +1,12 @@ +output "release_name" { + value = helm_release.agentic_data_kernel.name +} + +output "namespace" { + value = helm_release.agentic_data_kernel.namespace +} + +output "cluster_endpoint" { + value = data.google_container_cluster.target.endpoint + sensitive = true +} diff --git a/deploy/gcp/terraform.tfvars.example b/deploy/gcp/terraform.tfvars.example new file mode 100644 index 0000000..3c1512a --- /dev/null +++ b/deploy/gcp/terraform.tfvars.example @@ -0,0 +1,27 @@ +# Copy this file, then uncomment and replace every example before deployment. +# project_id = "example-project" +# location = "us-central1" +# cluster_name = "agentic-autopilot" +# namespace = "agentic-data" +# release_name = "agentic-data" +# image_tag = "0.3.0-alpha.5" +# image_digest = "" +# artifact_pvc_name = "agentic-data-filestore" +# runtime_kubernetes_secret_name = "agentic-data-runtime" +# admin_kubernetes_secret_name = "agentic-data-admin" +# job_service_account_name = "agentic-data-jobs" +# gcp_service_account_email = "agentic-data@example-project.iam.gserviceaccount.com" +# cloud_sql_instance_connection_name = "example-project:us-central1:agentic-data" +# database_proxy_image = "gcr.io/cloud-sql-connectors/cloud-sql-proxy:2.18.2" +# embedding_base_url = "https://api.openai.com/v1" +# artifact_current_key_id = "v1" +# effect_allowed_hosts = "payments.example.com,deployments.example.com" +# +# ingress_enabled = true +# ingress_class_name = "" +# ingress_host = "agentic-data.example.com" +# ingress_tls_secret_name = "agentic-data-tls" +# ingress_annotations = { +# "kubernetes.io/ingress.class" = "gce" +# "kubernetes.io/ingress.global-static-ip-name" = "agentic-data" +# } diff --git a/deploy/gcp/variables.tf b/deploy/gcp/variables.tf new file mode 100644 index 0000000..1481f39 --- /dev/null +++ b/deploy/gcp/variables.tf @@ -0,0 +1,164 @@ +variable "project_id" { + description = "Google Cloud project containing the existing GKE cluster." + type = string +} + +variable "location" { + description = "GKE cluster region or zone." + type = string +} + +variable "cluster_name" { + description = "Existing private GKE cluster name." + type = string +} + +variable "namespace" { + type = string + default = "agentic-data" +} + +variable "release_name" { + type = string + default = "agentic-data" +} + +variable "image_repository" { + type = string + default = "ghcr.io/jason-doyle/agentic-data-kernel" +} + +variable "image_tag" { + description = "Immutable Agentic Data Kernel release tag." + type = string + default = "" +} + +variable "image_digest" { + description = "Optional sha256 OCI digest used instead of image_tag." + type = string + default = "" +} + +variable "runtime_kubernetes_secret_name" { + description = "Existing Secret containing runtime-only keys." + type = string + default = "agentic-data-runtime" +} + +variable "admin_kubernetes_secret_name" { + description = "Existing Secret containing bootstrap and migration keys." + type = string + default = "agentic-data-admin" +} + +variable "artifact_pvc_name" { + description = "Existing ReadWriteMany PVC backed by Filestore." + type = string +} + +variable "service_account_name" { + description = "Kubernetes service account name." + type = string + default = "agentic-data" +} + +variable "service_account_annotations" { + description = "Optional Workload Identity annotations." + type = map(string) + default = {} +} + +variable "job_service_account_name" { + description = "Pre-created Kubernetes service account used by Helm hook Jobs." + type = string + default = "agentic-data-jobs" +} + +variable "gcp_service_account_email" { + description = "Google service account with roles/cloudsql.client." + type = string +} + +variable "cloud_sql_instance_connection_name" { + description = "Cloud SQL connection name in project:region:instance form." + type = string +} + +variable "database_proxy_image" { + description = "Immutable Cloud SQL Auth Proxy image." + type = string +} + +variable "embedding_base_url" { + type = string +} + +variable "embedding_model" { + type = string + default = "text-embedding-3-small" +} + +variable "embedding_version" { + type = string + default = "openai-compatible-v1" +} + +variable "embedding_dimensions" { + type = number + default = 1536 + + validation { + condition = ( + var.embedding_dimensions >= 1 && + var.embedding_dimensions <= 2000 + ) + error_message = "embedding_dimensions must be from 1 through 2000." + } +} + +variable "artifact_current_key_id" { + description = "Current key ID present in ARTIFACT_KEYRING." + type = string + default = "v1" +} + +variable "effect_allowed_hosts" { + type = string + default = "" +} + +variable "api_replica_count" { + type = number + default = 1 +} + +variable "worker_replica_count" { + type = number + default = 1 +} + +variable "ingress_enabled" { + type = bool + default = false +} + +variable "ingress_class_name" { + type = string + default = "" +} + +variable "ingress_host" { + type = string + default = "agentic-data.example.com" +} + +variable "ingress_annotations" { + type = map(string) + default = {} +} + +variable "ingress_tls_secret_name" { + type = string + default = "" +} diff --git a/deploy/gcp/versions.tf b/deploy/gcp/versions.tf new file mode 100644 index 0000000..53ece61 --- /dev/null +++ b/deploy/gcp/versions.tf @@ -0,0 +1,14 @@ +terraform { + required_version = ">= 1.9.0, < 2.0.0" + + required_providers { + google = { + source = "hashicorp/google" + version = "~> 7.0" + } + helm = { + source = "hashicorp/helm" + version = "~> 2.17" + } + } +} diff --git a/deploy/kubernetes/helm/agentic-data-kernel/Chart.yaml b/deploy/kubernetes/helm/agentic-data-kernel/Chart.yaml new file mode 100644 index 0000000..b474b3b --- /dev/null +++ b/deploy/kubernetes/helm/agentic-data-kernel/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: agentic-data-kernel +description: Agent-first persistence API and effect worker +type: application +version: 0.1.0 +appVersion: "0.3.0-alpha.5" diff --git a/deploy/kubernetes/helm/agentic-data-kernel/README.md b/deploy/kubernetes/helm/agentic-data-kernel/README.md new file mode 100644 index 0000000..fb13fba --- /dev/null +++ b/deploy/kubernetes/helm/agentic-data-kernel/README.md @@ -0,0 +1,94 @@ +# Kubernetes Helm Chart + +This chart deploys the Agentic Data Kernel API and effect worker against an +external PostgreSQL 18 database. + +## Prerequisites + +- Kubernetes 1.29 or newer +- PostgreSQL 18 with pgvector 0.8+ and pgcrypto +- a migration identity with `CREATEROLE` +- a ReadWriteMany filesystem supporting hard links and file fsync +- an externally managed Secret +- a TLS-capable ingress controller when public access is enabled + +Create the Secret before installing: + +```powershell +kubectl create namespace agentic-data +$databaseUrl = Read-Host "agentic_app PostgreSQL URL" +$migrationDatabaseUrl = Read-Host "Administrative PostgreSQL URL" +$appDatabasePassword = Read-Host "Runtime role password" +$authPepper = Read-Host "AUTH_PEPPER" +$artifactKeyring = Read-Host "ARTIFACT_KEYRING JSON" +$embeddingApiKey = Read-Host "Embedding provider key" + +kubectl -n agentic-data create secret generic agentic-data-runtime ` + --from-literal=DATABASE_URL=$databaseUrl ` + --from-literal=AUTH_PEPPER=$authPepper ` + --from-literal=ARTIFACT_KEYRING=$artifactKeyring ` + --from-literal=EMBEDDING_API_KEY=$embeddingApiKey + +kubectl -n agentic-data create secret generic agentic-data-admin ` + --from-literal=MIGRATION_DATABASE_URL=$migrationDatabaseUrl ` + --from-literal=APP_DATABASE_PASSWORD=$appDatabasePassword +``` + +Prefer an external secret operator or provider-native secret integration for +repeatable environments. Direct `kubectl --from-literal` arguments can be +visible to local process inspection. + +Install: + +```powershell +helm upgrade --install agentic-data ` + .\deploy\kubernetes\helm\agentic-data-kernel ` + --namespace agentic-data ` + --set image.tag="0.3.0-alpha.5" ` + --set config.embeddingBaseUrl="https://api.openai.com/v1" ` + --set config.effectAllowedHosts="payments.example.com" +``` + +For managed cloud databases, set `config.databaseSsl=require`. Supply +`persistence.existingClaim` when the platform provisions the filesystem +outside the chart. + +The storage root must already be writable by UID/GID `10001`, or the CSI +driver must honor the pod `fsGroup`. The optional root init container is +disabled by default because restricted Pod Security policies and NFS +root-squash can reject ownership changes. Enable `artifactInit.enabled` only +after verifying the storage backend and namespace policy. + +`networkPolicy.enabled` defaults to false. When enabling it, provide explicit +`networkPolicy.ingressFrom` selectors and `networkPolicy.egress` rules for DNS, +PostgreSQL, the embedding endpoint, and approved effect destinations. Standard +Kubernetes NetworkPolicy cannot filter HTTPS by hostname. + +When enabling ingress, configure at least one `ingress.tls` entry and disable +plain HTTP through the selected ingress controller. Set +`ingress.tlsOnlyAnnotation` and `ingress.tlsOnlyValue` to its redirect or +HTTP-disable annotation, for example +`nginx.ingress.kubernetes.io/ssl-redirect=true`. + +Create the namespace and both Secrets before Helm runs. Runtime pods cannot +read the administrative Secret. The bootstrap and migration hooks +intentionally fail when their external Secret is absent. +They use `jobs.serviceAccountName`, which defaults to the namespace's existing +`default` service account because pre-install hooks run before chart-managed +service accounts are created. Point it at another pre-created service account +when jobs require cloud workload identity. + +Add `DATABASE_CA_CERT_BASE64` to the Secret when the PostgreSQL CA is not in +the container's system trust store. + +`databaseProxy.enabled` adds a sidecar to API and worker pods and a native +Kubernetes sidecar to both hook Jobs. When using an authenticated local proxy, +point both database URLs at its loopback port and set +`config.databaseSsl=disable`; the proxy owns encrypted upstream verification. +An in-pod init container waits for the loopback proxy port before any main +container starts. Kubernetes 1.29 or newer is required for native sidecars. + +Helm stores ordinary values in release history. Do not place credentials in a +values file or pass them with `--set`. +Always set either `image.tag` to an immutable release version or +`image.digest` to a `sha256:` OCI digest, never both. diff --git a/deploy/kubernetes/helm/agentic-data-kernel/templates/NOTES.txt b/deploy/kubernetes/helm/agentic-data-kernel/templates/NOTES.txt new file mode 100644 index 0000000..adc14d7 --- /dev/null +++ b/deploy/kubernetes/helm/agentic-data-kernel/templates/NOTES.txt @@ -0,0 +1,19 @@ +Agentic Data Kernel has been installed. + +Runtime Secret: {{ .Values.secretRef.runtimeName }} + DATABASE_URL + AUTH_PEPPER + ARTIFACT_KEYRING + EMBEDDING_API_KEY + DATABASE_CA_CERT_BASE64 (optional) + +Administrative Secret: {{ .Values.secretRef.adminName }} + MIGRATION_DATABASE_URL + APP_DATABASE_PASSWORD + DATABASE_CA_CERT_BASE64 (optional) + +API service: + {{ include "agentic-data-kernel.fullname" . }}:{{ .Values.service.port }} + +The bootstrap and migration jobs run as pre-install and pre-upgrade hooks. +Verify both jobs completed before exposing the API. diff --git a/deploy/kubernetes/helm/agentic-data-kernel/templates/_helpers.tpl b/deploy/kubernetes/helm/agentic-data-kernel/templates/_helpers.tpl new file mode 100644 index 0000000..0d963f3 --- /dev/null +++ b/deploy/kubernetes/helm/agentic-data-kernel/templates/_helpers.tpl @@ -0,0 +1,48 @@ +{{- define "agentic-data-kernel.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{- define "agentic-data-kernel.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name (include "agentic-data-kernel.name" .) | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} + +{{- define "agentic-data-kernel.labels" -}} +helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | quote }} +app.kubernetes.io/name: {{ include "agentic-data-kernel.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{- define "agentic-data-kernel.selectorLabels" -}} +app.kubernetes.io/name: {{ include "agentic-data-kernel.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{- define "agentic-data-kernel.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "agentic-data-kernel.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- required "serviceAccount.name is required when serviceAccount.create is false" .Values.serviceAccount.name }} +{{- end }} +{{- end }} + +{{- define "agentic-data-kernel.pvcName" -}} +{{- default (printf "%s-artifacts" (include "agentic-data-kernel.fullname" .)) .Values.persistence.existingClaim }} +{{- end }} + +{{- define "agentic-data-kernel.image" -}} +{{- if .Values.image.digest }} +{{- printf "%s@%s" .Values.image.repository .Values.image.digest }} +{{- else }} +{{- printf "%s:%s" .Values.image.repository (required "image.tag is required when image.digest is empty" .Values.image.tag) }} +{{- end }} +{{- end }} + +{{- define "agentic-data-kernel.configChecksum" -}} +{{- include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }} +{{- end }} diff --git a/deploy/kubernetes/helm/agentic-data-kernel/templates/api-deployment.yaml b/deploy/kubernetes/helm/agentic-data-kernel/templates/api-deployment.yaml new file mode 100644 index 0000000..933ca07 --- /dev/null +++ b/deploy/kubernetes/helm/agentic-data-kernel/templates/api-deployment.yaml @@ -0,0 +1,180 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "agentic-data-kernel.fullname" . }}-api + labels: + {{- include "agentic-data-kernel.labels" . | nindent 4 }} + app.kubernetes.io/component: api +spec: + replicas: {{ .Values.api.replicaCount }} + selector: + matchLabels: + {{- include "agentic-data-kernel.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: api + template: + metadata: + annotations: + checksum/config: {{ include "agentic-data-kernel.configChecksum" . }} + {{- with .Values.api.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "agentic-data-kernel.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: api + spec: + serviceAccountName: {{ include "agentic-data-kernel.serviceAccountName" . }} + automountServiceAccountToken: {{ .Values.serviceAccount.automountServiceAccountToken }} + securityContext: + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + fsGroup: 10001 + fsGroupChangePolicy: OnRootMismatch + {{- if or .Values.databaseProxy.enabled .Values.artifactInit.enabled }} + initContainers: + {{- if .Values.databaseProxy.enabled }} + - name: database-proxy + restartPolicy: Always + image: {{ .Values.databaseProxy.image | quote }} + imagePullPolicy: IfNotPresent + args: + {{- toYaml .Values.databaseProxy.args | nindent 12 }} + ports: + - name: database-proxy + containerPort: {{ .Values.databaseProxy.port }} + protocol: TCP + resources: + {{- toYaml .Values.databaseProxy.resources | nindent 12 }} + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + capabilities: + drop: ["ALL"] + - name: database-proxy-ready + image: {{ .Values.databaseProxy.waitImage | quote }} + command: + - sh + - -ec + - for attempt in $(seq 1 60); do nc -z 127.0.0.1 {{ .Values.databaseProxy.port }} && exit 0; sleep 2; done; exit 1 + resources: + requests: + cpu: 10m + memory: 16Mi + limits: + cpu: 100m + memory: 64Mi + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + capabilities: + drop: ["ALL"] + {{- end }} + {{- if .Values.artifactInit.enabled }} + - name: artifact-permissions + image: {{ .Values.artifactInit.image | quote }} + command: + - sh + - -ec + - mkdir -p /artifacts && chown 10001:10001 /artifacts && chmod 700 /artifacts + securityContext: + runAsUser: 0 + runAsNonRoot: false + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + add: ["CHOWN", "FOWNER", "DAC_OVERRIDE"] + volumeMounts: + - name: artifacts + mountPath: /artifacts + {{- end }} + {{- end }} + containers: + - name: api + image: {{ include "agentic-data-kernel.image" . | quote }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + command: + - node + - dist/production/cli.js + - serve + ports: + - name: http + containerPort: 4318 + protocol: TCP + envFrom: + - configMapRef: + name: {{ include "agentic-data-kernel.fullname" . }} + env: + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: {{ .Values.secretRef.runtimeName }} + key: DATABASE_URL + - name: AUTH_PEPPER + valueFrom: + secretKeyRef: + name: {{ .Values.secretRef.runtimeName }} + key: AUTH_PEPPER + - name: ARTIFACT_KEYRING + valueFrom: + secretKeyRef: + name: {{ .Values.secretRef.runtimeName }} + key: ARTIFACT_KEYRING + - name: EMBEDDING_API_KEY + valueFrom: + secretKeyRef: + name: {{ .Values.secretRef.runtimeName }} + key: EMBEDDING_API_KEY + - name: DATABASE_CA_CERT_BASE64 + valueFrom: + secretKeyRef: + name: {{ .Values.secretRef.runtimeName }} + key: DATABASE_CA_CERT_BASE64 + optional: true + readinessProbe: + httpGet: + path: /health/ready + port: http + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 3 + failureThreshold: 6 + livenessProbe: + httpGet: + path: /health/live + port: http + initialDelaySeconds: 20 + periodSeconds: 20 + timeoutSeconds: 3 + failureThreshold: 3 + resources: + {{- toYaml .Values.api.resources | nindent 12 }} + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + volumeMounts: + - name: artifacts + mountPath: {{ .Values.config.artifactDirectory }} + volumes: + - name: artifacts + persistentVolumeClaim: + claimName: {{ include "agentic-data-kernel.pvcName" . }} + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.api.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.api.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.api.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/deploy/kubernetes/helm/agentic-data-kernel/templates/bootstrap-job.yaml b/deploy/kubernetes/helm/agentic-data-kernel/templates/bootstrap-job.yaml new file mode 100644 index 0000000..22250c0 --- /dev/null +++ b/deploy/kubernetes/helm/agentic-data-kernel/templates/bootstrap-job.yaml @@ -0,0 +1,107 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ include "agentic-data-kernel.fullname" . }}-bootstrap + labels: + {{- include "agentic-data-kernel.labels" . | nindent 4 }} + app.kubernetes.io/component: bootstrap + annotations: + helm.sh/hook: pre-install,pre-upgrade + helm.sh/hook-weight: "-10" + helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded +spec: + backoffLimit: {{ .Values.jobs.backoffLimit }} + activeDeadlineSeconds: {{ .Values.jobs.activeDeadlineSeconds }} + template: + metadata: + labels: + {{- include "agentic-data-kernel.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: bootstrap + spec: + restartPolicy: Never + serviceAccountName: {{ .Values.jobs.serviceAccountName | quote }} + automountServiceAccountToken: {{ .Values.jobs.automountServiceAccountToken }} + securityContext: + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + {{- if .Values.databaseProxy.enabled }} + initContainers: + - name: database-proxy + restartPolicy: Always + image: {{ .Values.databaseProxy.image | quote }} + imagePullPolicy: IfNotPresent + args: + {{- toYaml .Values.databaseProxy.args | nindent 12 }} + ports: + - name: database-proxy + containerPort: {{ .Values.databaseProxy.port }} + protocol: TCP + resources: + {{- toYaml .Values.databaseProxy.resources | nindent 12 }} + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + capabilities: + drop: ["ALL"] + - name: database-proxy-ready + image: {{ .Values.databaseProxy.waitImage | quote }} + command: + - sh + - -ec + - for attempt in $(seq 1 60); do nc -z 127.0.0.1 {{ .Values.databaseProxy.port }} && exit 0; sleep 2; done; exit 1 + resources: + requests: + cpu: 10m + memory: 16Mi + limits: + cpu: 100m + memory: 64Mi + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + capabilities: + drop: ["ALL"] + {{- end }} + containers: + - name: bootstrap + image: {{ include "agentic-data-kernel.image" . | quote }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + command: + - node + - dist/production/cli.js + - bootstrap-role + env: + - name: MIGRATION_DATABASE_URL + valueFrom: + secretKeyRef: + name: {{ .Values.secretRef.adminName }} + key: MIGRATION_DATABASE_URL + - name: APP_DATABASE_PASSWORD + valueFrom: + secretKeyRef: + name: {{ .Values.secretRef.adminName }} + key: APP_DATABASE_PASSWORD + - name: DATABASE_CA_CERT_BASE64 + valueFrom: + secretKeyRef: + name: {{ .Values.secretRef.adminName }} + key: DATABASE_CA_CERT_BASE64 + optional: true + - name: DATABASE_SSL + value: {{ .Values.config.databaseSsl | quote }} + - name: DATABASE_STATEMENT_TIMEOUT_MS + value: {{ .Values.config.databaseStatementTimeoutMs | quote }} + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/deploy/kubernetes/helm/agentic-data-kernel/templates/configmap.yaml b/deploy/kubernetes/helm/agentic-data-kernel/templates/configmap.yaml new file mode 100644 index 0000000..8c4357f --- /dev/null +++ b/deploy/kubernetes/helm/agentic-data-kernel/templates/configmap.yaml @@ -0,0 +1,29 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: {{ include "agentic-data-kernel.fullname" . }} + labels: + {{- include "agentic-data-kernel.labels" . | nindent 4 }} +data: + DATABASE_SSL: {{ .Values.config.databaseSsl | quote }} + DATABASE_POOL_SIZE: {{ .Values.config.databasePoolSize | quote }} + DATABASE_STATEMENT_TIMEOUT_MS: {{ .Values.config.databaseStatementTimeoutMs | quote }} + ARTIFACT_CURRENT_KEY_ID: {{ .Values.config.artifactCurrentKeyId | quote }} + ARTIFACT_DIR: {{ .Values.config.artifactDirectory | quote }} + EMBEDDING_BASE_URL: {{ .Values.config.embeddingBaseUrl | quote }} + EMBEDDING_MODEL: {{ .Values.config.embeddingModel | quote }} + EMBEDDING_VERSION: {{ .Values.config.embeddingVersion | quote }} + EMBEDDING_DIMENSIONS: {{ .Values.config.embeddingDimensions | quote }} + EMBEDDING_TIMEOUT_MS: {{ .Values.config.embeddingTimeoutMs | quote }} + SEARCH_CANDIDATE_LIMIT: {{ .Values.config.searchCandidateLimit | quote }} + HNSW_EF_SEARCH: {{ .Values.config.hnswEfSearch | quote }} + HNSW_MAX_SCAN_TUPLES: {{ .Values.config.hnswMaxScanTuples | quote }} + EFFECT_ALLOWED_HOSTS: {{ .Values.config.effectAllowedHosts | quote }} + EFFECT_TIMEOUT_MS: {{ .Values.config.effectTimeoutMs | quote }} + EFFECT_LEASE_SECONDS: {{ .Values.config.effectLeaseSeconds | quote }} + EFFECT_MAX_ATTEMPTS: {{ .Values.config.effectMaxAttempts | quote }} + LOG_LEVEL: {{ .Values.config.logLevel | quote }} + MAX_BODY_BYTES: {{ .Values.config.maxBodyBytes | quote }} + RATE_LIMIT_PER_MINUTE: {{ .Values.config.rateLimitPerMinute | quote }} + HOST: "0.0.0.0" + PORT: "4318" diff --git a/deploy/kubernetes/helm/agentic-data-kernel/templates/ingress.yaml b/deploy/kubernetes/helm/agentic-data-kernel/templates/ingress.yaml new file mode 100644 index 0000000..e15e70f --- /dev/null +++ b/deploy/kubernetes/helm/agentic-data-kernel/templates/ingress.yaml @@ -0,0 +1,36 @@ +{{- if .Values.ingress.enabled }} +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: {{ include "agentic-data-kernel.fullname" . }} + labels: + {{- include "agentic-data-kernel.labels" . | nindent 4 }} + annotations: + {{- with .Values.ingress.annotations }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{ .Values.ingress.tlsOnlyAnnotation }}: {{ .Values.ingress.tlsOnlyValue | quote }} +spec: + {{- with .Values.ingress.className }} + ingressClassName: {{ . | quote }} + {{- end }} + {{- with .Values.ingress.tls }} + tls: + {{- toYaml . | nindent 4 }} + {{- end }} + rules: + {{- range .Values.ingress.hosts }} + - host: {{ .host | quote }} + http: + paths: + {{- range .paths }} + - path: {{ .path }} + pathType: {{ .pathType }} + backend: + service: + name: {{ include "agentic-data-kernel.fullname" $ }} + port: + name: http + {{- end }} + {{- end }} +{{- end }} diff --git a/deploy/kubernetes/helm/agentic-data-kernel/templates/migrate-job.yaml b/deploy/kubernetes/helm/agentic-data-kernel/templates/migrate-job.yaml new file mode 100644 index 0000000..ccb7077 --- /dev/null +++ b/deploy/kubernetes/helm/agentic-data-kernel/templates/migrate-job.yaml @@ -0,0 +1,108 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: {{ include "agentic-data-kernel.fullname" . }}-migrate + labels: + {{- include "agentic-data-kernel.labels" . | nindent 4 }} + app.kubernetes.io/component: migrate + annotations: + helm.sh/hook: pre-install,pre-upgrade + helm.sh/hook-weight: "-5" + helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded +spec: + backoffLimit: {{ .Values.jobs.backoffLimit }} + activeDeadlineSeconds: {{ .Values.jobs.activeDeadlineSeconds }} + template: + metadata: + labels: + {{- include "agentic-data-kernel.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: migrate + spec: + restartPolicy: Never + serviceAccountName: {{ .Values.jobs.serviceAccountName | quote }} + automountServiceAccountToken: {{ .Values.jobs.automountServiceAccountToken }} + securityContext: + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + {{- if .Values.databaseProxy.enabled }} + initContainers: + - name: database-proxy + restartPolicy: Always + image: {{ .Values.databaseProxy.image | quote }} + imagePullPolicy: IfNotPresent + args: + {{- toYaml .Values.databaseProxy.args | nindent 12 }} + ports: + - name: database-proxy + containerPort: {{ .Values.databaseProxy.port }} + protocol: TCP + resources: + {{- toYaml .Values.databaseProxy.resources | nindent 12 }} + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + capabilities: + drop: ["ALL"] + - name: database-proxy-ready + image: {{ .Values.databaseProxy.waitImage | quote }} + command: + - sh + - -ec + - for attempt in $(seq 1 60); do nc -z 127.0.0.1 {{ .Values.databaseProxy.port }} && exit 0; sleep 2; done; exit 1 + resources: + requests: + cpu: 10m + memory: 16Mi + limits: + cpu: 100m + memory: 64Mi + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + capabilities: + drop: ["ALL"] + {{- end }} + containers: + - name: migrate + image: {{ include "agentic-data-kernel.image" . | quote }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + command: + - node + - dist/production/cli.js + - migrate + env: + - name: MIGRATION_DATABASE_URL + valueFrom: + secretKeyRef: + name: {{ .Values.secretRef.adminName }} + key: MIGRATION_DATABASE_URL + - name: DATABASE_CA_CERT_BASE64 + valueFrom: + secretKeyRef: + name: {{ .Values.secretRef.adminName }} + key: DATABASE_CA_CERT_BASE64 + optional: true + - name: DATABASE_SSL + value: {{ .Values.config.databaseSsl | quote }} + - name: DATABASE_STATEMENT_TIMEOUT_MS + value: {{ .Values.config.databaseStatementTimeoutMs | quote }} + - name: EMBEDDING_MODEL + value: {{ .Values.config.embeddingModel | quote }} + - name: EMBEDDING_VERSION + value: {{ .Values.config.embeddingVersion | quote }} + - name: EMBEDDING_DIMENSIONS + value: {{ .Values.config.embeddingDimensions | quote }} + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/deploy/kubernetes/helm/agentic-data-kernel/templates/networkpolicy.yaml b/deploy/kubernetes/helm/agentic-data-kernel/templates/networkpolicy.yaml new file mode 100644 index 0000000..118e8fe --- /dev/null +++ b/deploy/kubernetes/helm/agentic-data-kernel/templates/networkpolicy.yaml @@ -0,0 +1,29 @@ +{{- if .Values.networkPolicy.enabled }} +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: {{ include "agentic-data-kernel.fullname" . }} + labels: + {{- include "agentic-data-kernel.labels" . | nindent 4 }} +spec: + podSelector: + matchLabels: + {{- include "agentic-data-kernel.selectorLabels" . | nindent 6 }} + policyTypes: + - Ingress + - Egress + {{- if .Values.networkPolicy.ingressFrom }} + ingress: + - {{- with .Values.networkPolicy.ingressFrom }} + from: + {{- toYaml . | nindent 8 }} + {{- end }} + ports: + - port: 4318 + protocol: TCP + {{- else }} + ingress: [] + {{- end }} + egress: + {{- toYaml .Values.networkPolicy.egress | nindent 4 }} +{{- end }} diff --git a/deploy/kubernetes/helm/agentic-data-kernel/templates/pdb.yaml b/deploy/kubernetes/helm/agentic-data-kernel/templates/pdb.yaml new file mode 100644 index 0000000..e67774b --- /dev/null +++ b/deploy/kubernetes/helm/agentic-data-kernel/templates/pdb.yaml @@ -0,0 +1,14 @@ +{{- if .Values.podDisruptionBudget.enabled }} +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: {{ include "agentic-data-kernel.fullname" . }}-api + labels: + {{- include "agentic-data-kernel.labels" . | nindent 4 }} +spec: + maxUnavailable: {{ .Values.podDisruptionBudget.maxUnavailable }} + selector: + matchLabels: + {{- include "agentic-data-kernel.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: api +{{- end }} diff --git a/deploy/kubernetes/helm/agentic-data-kernel/templates/pvc.yaml b/deploy/kubernetes/helm/agentic-data-kernel/templates/pvc.yaml new file mode 100644 index 0000000..2f331a9 --- /dev/null +++ b/deploy/kubernetes/helm/agentic-data-kernel/templates/pvc.yaml @@ -0,0 +1,21 @@ +{{- if and .Values.persistence.enabled (not .Values.persistence.existingClaim) }} +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: {{ include "agentic-data-kernel.pvcName" . }} + labels: + {{- include "agentic-data-kernel.labels" . | nindent 4 }} + {{- with .Values.persistence.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + accessModes: + {{- toYaml .Values.persistence.accessModes | nindent 4 }} + {{- with .Values.persistence.storageClass }} + storageClassName: {{ . | quote }} + {{- end }} + resources: + requests: + storage: {{ .Values.persistence.size }} +{{- end }} diff --git a/deploy/kubernetes/helm/agentic-data-kernel/templates/service.yaml b/deploy/kubernetes/helm/agentic-data-kernel/templates/service.yaml new file mode 100644 index 0000000..e6ea79d --- /dev/null +++ b/deploy/kubernetes/helm/agentic-data-kernel/templates/service.yaml @@ -0,0 +1,20 @@ +apiVersion: v1 +kind: Service +metadata: + name: {{ include "agentic-data-kernel.fullname" . }} + labels: + {{- include "agentic-data-kernel.labels" . | nindent 4 }} + {{- with .Values.service.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + type: {{ .Values.service.type }} + ports: + - name: http + port: {{ .Values.service.port }} + targetPort: http + protocol: TCP + selector: + {{- include "agentic-data-kernel.selectorLabels" . | nindent 4 }} + app.kubernetes.io/component: api diff --git a/deploy/kubernetes/helm/agentic-data-kernel/templates/serviceaccount.yaml b/deploy/kubernetes/helm/agentic-data-kernel/templates/serviceaccount.yaml new file mode 100644 index 0000000..6e36c87 --- /dev/null +++ b/deploy/kubernetes/helm/agentic-data-kernel/templates/serviceaccount.yaml @@ -0,0 +1,13 @@ +{{- if .Values.serviceAccount.create }} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "agentic-data-kernel.serviceAccountName" . }} + labels: + {{- include "agentic-data-kernel.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: {{ .Values.serviceAccount.automountServiceAccountToken }} +{{- end }} diff --git a/deploy/kubernetes/helm/agentic-data-kernel/templates/worker-deployment.yaml b/deploy/kubernetes/helm/agentic-data-kernel/templates/worker-deployment.yaml new file mode 100644 index 0000000..94a7026 --- /dev/null +++ b/deploy/kubernetes/helm/agentic-data-kernel/templates/worker-deployment.yaml @@ -0,0 +1,160 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "agentic-data-kernel.fullname" . }}-worker + labels: + {{- include "agentic-data-kernel.labels" . | nindent 4 }} + app.kubernetes.io/component: worker +spec: + replicas: {{ .Values.worker.replicaCount }} + selector: + matchLabels: + {{- include "agentic-data-kernel.selectorLabels" . | nindent 6 }} + app.kubernetes.io/component: worker + template: + metadata: + annotations: + checksum/config: {{ include "agentic-data-kernel.configChecksum" . }} + {{- with .Values.worker.podAnnotations }} + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "agentic-data-kernel.selectorLabels" . | nindent 8 }} + app.kubernetes.io/component: worker + spec: + serviceAccountName: {{ include "agentic-data-kernel.serviceAccountName" . }} + automountServiceAccountToken: {{ .Values.serviceAccount.automountServiceAccountToken }} + securityContext: + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + fsGroup: 10001 + fsGroupChangePolicy: OnRootMismatch + {{- if or .Values.databaseProxy.enabled .Values.artifactInit.enabled }} + initContainers: + {{- if .Values.databaseProxy.enabled }} + - name: database-proxy + restartPolicy: Always + image: {{ .Values.databaseProxy.image | quote }} + imagePullPolicy: IfNotPresent + args: + {{- toYaml .Values.databaseProxy.args | nindent 12 }} + ports: + - name: database-proxy + containerPort: {{ .Values.databaseProxy.port }} + protocol: TCP + resources: + {{- toYaml .Values.databaseProxy.resources | nindent 12 }} + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + capabilities: + drop: ["ALL"] + - name: database-proxy-ready + image: {{ .Values.databaseProxy.waitImage | quote }} + command: + - sh + - -ec + - for attempt in $(seq 1 60); do nc -z 127.0.0.1 {{ .Values.databaseProxy.port }} && exit 0; sleep 2; done; exit 1 + resources: + requests: + cpu: 10m + memory: 16Mi + limits: + cpu: 100m + memory: 64Mi + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + capabilities: + drop: ["ALL"] + {{- end }} + {{- if .Values.artifactInit.enabled }} + - name: artifact-permissions + image: {{ .Values.artifactInit.image | quote }} + command: + - sh + - -ec + - mkdir -p /artifacts && chown 10001:10001 /artifacts && chmod 700 /artifacts + securityContext: + runAsUser: 0 + runAsNonRoot: false + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + add: ["CHOWN", "FOWNER", "DAC_OVERRIDE"] + volumeMounts: + - name: artifacts + mountPath: /artifacts + {{- end }} + {{- end }} + containers: + - name: worker + image: {{ include "agentic-data-kernel.image" . | quote }} + imagePullPolicy: {{ .Values.image.pullPolicy }} + command: + - node + - dist/production/cli.js + - worker + envFrom: + - configMapRef: + name: {{ include "agentic-data-kernel.fullname" . }} + env: + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: {{ .Values.secretRef.runtimeName }} + key: DATABASE_URL + - name: AUTH_PEPPER + valueFrom: + secretKeyRef: + name: {{ .Values.secretRef.runtimeName }} + key: AUTH_PEPPER + - name: ARTIFACT_KEYRING + valueFrom: + secretKeyRef: + name: {{ .Values.secretRef.runtimeName }} + key: ARTIFACT_KEYRING + - name: EMBEDDING_API_KEY + valueFrom: + secretKeyRef: + name: {{ .Values.secretRef.runtimeName }} + key: EMBEDDING_API_KEY + - name: DATABASE_CA_CERT_BASE64 + valueFrom: + secretKeyRef: + name: {{ .Values.secretRef.runtimeName }} + key: DATABASE_CA_CERT_BASE64 + optional: true + resources: + {{- toYaml .Values.worker.resources | nindent 12 }} + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + volumeMounts: + - name: artifacts + mountPath: {{ .Values.config.artifactDirectory }} + volumes: + - name: artifacts + persistentVolumeClaim: + claimName: {{ include "agentic-data-kernel.pvcName" . }} + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.worker.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.worker.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.worker.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/deploy/kubernetes/helm/agentic-data-kernel/values.schema.json b/deploy/kubernetes/helm/agentic-data-kernel/values.schema.json new file mode 100644 index 0000000..f88da0d --- /dev/null +++ b/deploy/kubernetes/helm/agentic-data-kernel/values.schema.json @@ -0,0 +1,182 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "required": ["image", "secretRef", "config", "persistence"], + "properties": { + "image": { + "type": "object", + "required": ["repository", "tag", "digest"], + "properties": { + "repository": { "type": "string", "minLength": 1 }, + "tag": { "type": "string" }, + "digest": { "type": "string" }, + "pullPolicy": { + "type": "string", + "enum": ["Always", "IfNotPresent", "Never"] + } + }, + "oneOf": [ + { + "properties": { + "tag": { "minLength": 1 }, + "digest": { "maxLength": 0 } + } + }, + { + "properties": { + "tag": { "maxLength": 0 }, + "digest": { + "pattern": "^sha256:[a-f0-9]{64}$" + } + } + } + ] + }, + "secretRef": { + "type": "object", + "required": ["runtimeName", "adminName"], + "properties": { + "runtimeName": { "type": "string", "minLength": 1 }, + "adminName": { "type": "string", "minLength": 1 } + } + }, + "config": { + "type": "object", + "required": [ + "databaseSsl", + "artifactCurrentKeyId", + "artifactDirectory", + "embeddingBaseUrl", + "embeddingModel", + "embeddingVersion", + "embeddingDimensions" + ], + "properties": { + "databaseSsl": { + "type": "string", + "enum": ["disable", "require"] + }, + "artifactCurrentKeyId": { "type": "string", "minLength": 1 }, + "artifactDirectory": { "type": "string", "minLength": 1 }, + "embeddingBaseUrl": { + "type": "string", + "format": "uri", + "minLength": 1 + }, + "embeddingModel": { "type": "string", "minLength": 1 }, + "embeddingVersion": { "type": "string", "minLength": 1 }, + "embeddingDimensions": { + "type": "string", + "pattern": "^([1-9][0-9]{0,2}|1[0-9]{3}|2000)$" + } + } + }, + "persistence": { + "type": "object", + "required": ["enabled", "accessModes", "size"], + "properties": { + "enabled": { "const": true }, + "existingClaim": { "type": "string" }, + "storageClass": { "type": "string" }, + "accessModes": { + "type": "array", + "minItems": 1, + "items": { "const": "ReadWriteMany" } + }, + "size": { "type": "string", "minLength": 1 } + } + }, + "ingress": { + "type": "object", + "required": [ + "enabled", + "tls", + "tlsOnlyAnnotation", + "tlsOnlyValue" + ], + "properties": { + "enabled": { "type": "boolean" }, + "tlsOnlyAnnotation": { "type": "string" }, + "tlsOnlyValue": { "type": "string" }, + "tls": { + "type": "array", + "items": { + "type": "object", + "required": ["secretName", "hosts"], + "properties": { + "secretName": { "type": "string", "minLength": 1 }, + "hosts": { + "type": "array", + "minItems": 1, + "items": { "type": "string", "minLength": 1 } + } + } + } + } + } + }, + "databaseProxy": { + "type": "object", + "required": ["enabled", "image", "waitImage", "args", "port"], + "properties": { + "enabled": { "type": "boolean" }, + "image": { "type": "string" }, + "waitImage": { "type": "string", "minLength": 1 }, + "args": { + "type": "array", + "items": { "type": "string" } + }, + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + } + } + } + }, + "allOf": [ + { + "if": { + "properties": { + "ingress": { + "properties": { + "enabled": { "const": true } + } + } + } + }, + "then": { + "properties": { + "ingress": { + "properties": { + "tls": { "minItems": 1 }, + "tlsOnlyAnnotation": { "minLength": 1 }, + "tlsOnlyValue": { "minLength": 1 } + } + } + } + } + }, + { + "if": { + "properties": { + "databaseProxy": { + "properties": { + "enabled": { "const": true } + } + } + } + }, + "then": { + "properties": { + "databaseProxy": { + "properties": { + "image": { "minLength": 1 }, + "args": { "minItems": 1 } + } + } + } + } + } + ] +} diff --git a/deploy/kubernetes/helm/agentic-data-kernel/values.yaml b/deploy/kubernetes/helm/agentic-data-kernel/values.yaml new file mode 100644 index 0000000..1dbca67 --- /dev/null +++ b/deploy/kubernetes/helm/agentic-data-kernel/values.yaml @@ -0,0 +1,129 @@ +image: + repository: ghcr.io/jason-doyle/agentic-data-kernel + tag: "" + digest: "" + pullPolicy: IfNotPresent + +imagePullSecrets: [] +nameOverride: "" +fullnameOverride: "" + +serviceAccount: + create: true + annotations: {} + name: "" + automountServiceAccountToken: false + +secretRef: + runtimeName: agentic-data-runtime + adminName: agentic-data-admin + +config: + databaseSsl: require + databasePoolSize: "10" + databaseStatementTimeoutMs: "30000" + artifactCurrentKeyId: v1 + artifactDirectory: /var/lib/agentic-data/artifacts + embeddingBaseUrl: https://api.openai.com/v1 + embeddingModel: text-embedding-3-small + embeddingVersion: openai-compatible-v1 + embeddingDimensions: "1536" + embeddingTimeoutMs: "30000" + searchCandidateLimit: "200" + hnswEfSearch: "100" + hnswMaxScanTuples: "20000" + effectAllowedHosts: "" + effectTimeoutMs: "15000" + effectLeaseSeconds: "30" + effectMaxAttempts: "10" + logLevel: info + maxBodyBytes: "1000000" + rateLimitPerMinute: "600" + +api: + replicaCount: 1 + podAnnotations: {} + resources: + requests: + cpu: 250m + memory: 512Mi + limits: + cpu: "1" + memory: 2Gi + nodeSelector: {} + tolerations: [] + affinity: {} + +worker: + replicaCount: 1 + podAnnotations: {} + resources: + requests: + cpu: 250m + memory: 512Mi + limits: + cpu: "1" + memory: 2Gi + nodeSelector: {} + tolerations: [] + affinity: {} + +jobs: + backoffLimit: 3 + activeDeadlineSeconds: 900 + serviceAccountName: default + automountServiceAccountToken: false + +databaseProxy: + enabled: false + image: "" + waitImage: busybox:1.37.0 + args: [] + port: 5432 + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 512Mi + +persistence: + enabled: true + existingClaim: "" + storageClass: "" + accessModes: + - ReadWriteMany + size: 10Gi + annotations: {} + +artifactInit: + enabled: false + image: busybox:1.37.0 + +service: + type: ClusterIP + port: 80 + annotations: {} + +ingress: + enabled: false + className: "" + annotations: {} + tlsOnlyAnnotation: "" + tlsOnlyValue: "" + hosts: + - host: agentic-data.example.com + paths: + - path: / + pathType: Prefix + tls: [] + +podDisruptionBudget: + enabled: false + maxUnavailable: 1 + +networkPolicy: + enabled: false + ingressFrom: [] + egress: [] diff --git a/docker-compose.yml b/docker-compose.yml index 136f123..61a8dde 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -70,6 +70,8 @@ services: condition: service_completed_successfully environment: DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required}@postgres:5432/agentic_data + DATABASE_SSL: ${DATABASE_SSL:-disable} + DATABASE_CA_CERT_BASE64: ${DATABASE_CA_CERT_BASE64:-} EMBEDDING_MODEL: ${EMBEDDING_MODEL:-text-embedding-3-small} EMBEDDING_VERSION: ${EMBEDDING_VERSION:-openai-compatible-v1} EMBEDDING_DIMENSIONS: ${EMBEDDING_DIMENSIONS:-1536} @@ -102,6 +104,8 @@ services: condition: service_completed_successfully environment: DATABASE_URL: postgresql://agentic_app:${APP_DATABASE_PASSWORD:?APP_DATABASE_PASSWORD is required}@postgres:5432/agentic_data + DATABASE_SSL: ${DATABASE_SSL:-disable} + DATABASE_CA_CERT_BASE64: ${DATABASE_CA_CERT_BASE64:-} AUTH_PEPPER: ${AUTH_PEPPER:-} ARTIFACT_KEYRING: ${ARTIFACT_KEYRING:-} ARTIFACT_CURRENT_KEY_ID: ${ARTIFACT_CURRENT_KEY_ID:-v1} @@ -149,6 +153,8 @@ services: condition: service_completed_successfully environment: DATABASE_URL: postgresql://agentic_app:${APP_DATABASE_PASSWORD:?APP_DATABASE_PASSWORD is required}@postgres:5432/agentic_data + DATABASE_SSL: ${DATABASE_SSL:-disable} + DATABASE_CA_CERT_BASE64: ${DATABASE_CA_CERT_BASE64:-} AUTH_PEPPER: ${AUTH_PEPPER:-} ARTIFACT_KEYRING: ${ARTIFACT_KEYRING:-} ARTIFACT_CURRENT_KEY_ID: ${ARTIFACT_CURRENT_KEY_ID:-v1} diff --git a/docs/PRODUCTION.md b/docs/PRODUCTION.md index 874cc99..e280303 100644 --- a/docs/PRODUCTION.md +++ b/docs/PRODUCTION.md @@ -63,6 +63,12 @@ Generate local secrets: Copy `.env.example` to `.env`, replace every placeholder, and configure an OpenAI-compatible embeddings endpoint. +For managed PostgreSQL, set `DATABASE_SSL=require`. If the provider CA is not +already in the container's trust store, set `DATABASE_CA_CERT_BASE64` to the +base64 encoding of its PEM CA bundle. +Do not add SSL query parameters such as `sslmode` to `DATABASE_URL` or +`MIGRATION_DATABASE_URL`; use these dedicated settings. + The `prod:*` npm scripts load `.env` through Node's `--env-file` option. Start PostgreSQL and create the restricted role: @@ -72,6 +78,22 @@ docker compose up -d postgres docker compose run --rm bootstrap ``` +Cloud and Kubernetes deployments use the equivalent packaged command: + +```powershell +node dist\production\cli.js bootstrap-role +``` + +From a source checkout, `npm run prod:bootstrap` runs the same command with +`.env`. + +It reads `MIGRATION_DATABASE_URL` and `APP_DATABASE_PASSWORD`, then creates or +repairs the fixed `agentic_app` role with no superuser, database-creation, +role-creation, inheritance, or RLS-bypass privileges. +The password must contain 16 to 256 printable ASCII characters without spaces. +Existing `agentic_app` role memberships or object ownership cause bootstrap to +fail rather than preserve privilege-bearing state. + Apply migrations with the administrative connection: ```powershell @@ -119,6 +141,20 @@ npm run prod:serve npm run prod:worker ``` +## Cloud deployment templates + +Validated reference workload templates are available for: + +- Kubernetes through Helm; +- Azure Container Apps through Bicep; +- AWS ECS Fargate through OpenTofu; +- Google Kubernetes Engine through OpenTofu and Helm. + +They consume existing private PostgreSQL, secret-management, network, TLS, and +shared-filesystem resources rather than placing credentials in infrastructure +state. Read [Deployment Templates](../deploy/README.md) and the shared +[Deployment Contract](../deploy/CONTRACT.md). + ## HTTP authentication Every production API request except liveness, readiness, and metrics requires: diff --git a/package.json b/package.json index 0c17217..9697616 100644 --- a/package.json +++ b/package.json @@ -63,8 +63,10 @@ "scripts/backup.ps1", "scripts/generate-secrets.ps1", "scripts/restore.ps1", + "scripts/validate-deployments.ps1", "examples", "benchmarks", + "deploy", "docker", "docker-compose.yml", "Dockerfile", @@ -84,6 +86,7 @@ "test": "npm run build && node --no-warnings --test \"dist/test/*.test.js\"", "test:package": "node scripts/test-package.mjs", "release:check": "npm run check && npm test && npm run test:package", + "deployment:check": "pwsh -NoProfile -File scripts/validate-deployments.ps1", "example": "node --no-warnings dist/cli.js example --db .data/example.db", "example:all": "npm run example && npm run example:library && npm run example:mcp", "example:library": "node --no-warnings dist/examples/local-library.js", @@ -98,6 +101,7 @@ "benchmark:sre:verify": "npm run benchmark:sre -- --verify-results", "serve": "npm run build && node --no-warnings dist/cli.js serve --db .data/agentic.db", "mcp": "npm run build && node --no-warnings dist/cli.js mcp --db .data/agentic.db", + "prod:bootstrap": "npm run build && node --env-file=.env dist/production/cli.js bootstrap-role", "prod:migrate": "npm run build && node --env-file=.env dist/production/cli.js migrate", "prod:status": "npm run build && node --env-file=.env dist/production/cli.js migration-status", "prod:serve": "npm run build && node --env-file=.env dist/production/cli.js serve", diff --git a/scripts/test-package.mjs b/scripts/test-package.mjs index fafb6d0..755caa9 100644 --- a/scripts/test-package.mjs +++ b/scripts/test-package.mjs @@ -37,12 +37,20 @@ try { "dist/index.d.ts", "dist/production/index.js", "dist/production/index.d.ts", + "dist/production/bootstrap.js", + "dist/production/bootstrap.d.ts", "dist/examples/sre-scenario.js", "migrations/postgres/001_core.sql", "migrations/postgres/002_embedding_space.sql", "migrations/postgres/003_generic_agency.sql", "benchmarks/sre/README.md", "benchmarks/sre/baseline-schema.sql", + "deploy/CONTRACT.md", + "deploy/azure/main.bicep", + "deploy/aws/main.tf", + "deploy/gcp/main.tf", + "deploy/kubernetes/helm/agentic-data-kernel/Chart.yaml", + "scripts/validate-deployments.ps1", "README.md", "LICENSE", ]) { @@ -108,6 +116,7 @@ try { formatTraceExplanation, } from "agentic-data-kernel"; import { + bootstrapRuntimeRole, OpenAiCompatibleEmbeddingProvider, postgresMigrationDirectory, } from "agentic-data-kernel/production"; @@ -123,6 +132,9 @@ try { if (typeof OpenAiCompatibleEmbeddingProvider !== "function") { throw new Error("Production package export is unavailable"); } + if (typeof bootstrapRuntimeRole !== "function") { + throw new Error("Runtime role bootstrap export is unavailable"); + } if (typeof formatTraceExplanation !== "function") { throw new Error("Trace formatter export is unavailable"); } @@ -160,6 +172,7 @@ try { formatTraceExplanation, } from "agentic-data-kernel"; import { + bootstrapRuntimeRole, type EmbeddingSpace, ProductionDatabase, postgresMigrationDirectory, @@ -171,6 +184,7 @@ const knowledgeLayer: KnowledgeLayer = kernel.knowledge; const knowledgeOperation: KnowledgeOperationName = "assert"; const formatter: typeof formatTraceExplanation = formatTraceExplanation; const databaseType: typeof ProductionDatabase = ProductionDatabase; +const bootstrapType: typeof bootstrapRuntimeRole = bootstrapRuntimeRole; const migrationPath: string = postgresMigrationDirectory; const embeddingSpace: EmbeddingSpace = { model: "test", @@ -182,6 +196,7 @@ void knowledgeLayer; void knowledgeOperation; void formatter; void databaseType; +void bootstrapType; void migrationPath; void embeddingSpace; store.close(); diff --git a/scripts/validate-deployments.ps1 b/scripts/validate-deployments.ps1 new file mode 100644 index 0000000..9b9ae8e --- /dev/null +++ b/scripts/validate-deployments.ps1 @@ -0,0 +1,143 @@ +$ErrorActionPreference = "Stop" + +$root = Split-Path -Parent $PSScriptRoot +$helmImage = "alpine/helm:3.18.6" +$azureCliImage = "mcr.microsoft.com/azure-cli:2.77.0" +$tofuImage = "ghcr.io/opentofu/opentofu:1.10.6" + +function Invoke-Docker { + param([string[]]$Arguments) + + & docker @Arguments + if ($LASTEXITCODE -ne 0) { + throw "docker $($Arguments -join ' ') failed" + } +} + +function Invoke-DockerQuiet { + param([string[]]$Arguments) + + & docker @Arguments | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "docker $($Arguments -join ' ') failed" + } +} + +$chart = Join-Path $root "deploy\kubernetes\helm\agentic-data-kernel" +Invoke-DockerQuiet @( + "run", "--rm", + "--volume", "${chart}:/chart", + $helmImage, + "lint", "/chart", + "--set", "image.tag=validation" +) +$templateArguments = @( + "run", "--rm", + "--volume", "${chart}:/chart", + $helmImage, + "template", "agentic-data", "/chart", + "--namespace", "agentic-data", + "--set", "image.tag=validation", + "--set", "ingress.enabled=true", + "--set", "ingress.tls[0].secretName=validation-tls", + "--set", "ingress.tls[0].hosts[0]=agentic-data.example.com", + "--set-string", "ingress.tlsOnlyAnnotation=nginx.ingress.kubernetes.io/ssl-redirect", + "--set-string", "ingress.tlsOnlyValue=true", + "--set", "databaseProxy.enabled=true", + "--set", "databaseProxy.image=database-proxy:validation", + "--set-string", "databaseProxy.args[0]=--private-ip", + "--set-string", "databaseProxy.args[1]=project:region:instance" +) +$rendered = & docker @templateArguments +if ($LASTEXITCODE -ne 0) { + throw "Helm template rendering failed" +} +$renderedText = $rendered -join "`n" +$manifestExpectations = @{ + "name: DATABASE_URL" = 2 + "name: MIGRATION_DATABASE_URL" = 2 + "name: APP_DATABASE_PASSWORD" = 1 + "restartPolicy: Always" = 4 + "name: database-proxy-ready" = 4 +} +foreach ($expectation in $manifestExpectations.GetEnumerator()) { + $matches = [regex]::Matches( + $renderedText, + [regex]::Escape($expectation.Key) + ).Count + if ($matches -ne $expectation.Value) { + throw ( + "Rendered Helm manifests contain $matches instances of " + + "$($expectation.Key); expected $($expectation.Value)" + ) + } +} + +$azure = Join-Path $root "deploy\azure" +[scriptblock]::Create( + (Get-Content (Join-Path $azure "deploy.ps1") -Raw) +) | Out-Null +Invoke-DockerQuiet @( + "run", "--rm", + "--volume", "${azure}:/src", + $azureCliImage, + "az", "bicep", "build", + "--file", "/src/main.bicep", + "--stdout" +) + +$digest = "sha256:$("a" * 64)" +$digestArguments = @( + "run", "--rm", + "--volume", "${chart}:/chart", + $helmImage, + "template", "agentic-data-digest", "/chart", + "--namespace", "agentic-data", + "--set", "image.tag=", + "--set", "image.digest=$digest" +) +$digestRendered = (& docker @digestArguments) -join "`n" +if ($LASTEXITCODE -ne 0) { + throw "Helm digest rendering failed" +} +if (-not $digestRendered.Contains("@$digest")) { + throw "Helm digest rendering did not use OCI digest syntax" +} +foreach ($directory in @("aws", "gcp")) { + $module = Join-Path $root "deploy\$directory" + try { + Invoke-Docker @( + "run", "--rm", + "--volume", "${module}:/work", + "--workdir", "/work", + $tofuImage, + "fmt", "-check", "-recursive" + ) + Invoke-Docker @( + "run", "--rm", + "--volume", "${module}:/work", + "--workdir", "/work", + $tofuImage, + "init", "-backend=false", "-input=false" + ) + Invoke-Docker @( + "run", "--rm", + "--volume", "${module}:/work", + "--workdir", "/work", + $tofuImage, + "validate" + ) + } + finally { + Invoke-DockerQuiet @( + "run", "--rm", + "--volume", "${module}:/work", + "--workdir", "/work", + "--entrypoint", "sh", + $tofuImage, + "-c", "rm -rf /work/.terraform" + ) + } +} + +Write-Output "Deployment templates validated." diff --git a/src/production/bootstrap.ts b/src/production/bootstrap.ts new file mode 100644 index 0000000..e7b1233 --- /dev/null +++ b/src/production/bootstrap.ts @@ -0,0 +1,298 @@ +import { + createHash, + createHmac, + pbkdf2Sync, + randomBytes, +} from "node:crypto"; +import type { DatabaseConfig } from "./config.js"; +import { ProductionDatabase } from "./database.js"; + +const SCRAM_ITERATIONS = 4096; + +export interface RuntimeRoleBootstrapResult { + role: "agentic_app"; + created: boolean; +} + +export async function bootstrapRuntimeRole( + config: DatabaseConfig, + password: string, +): Promise { + if (!/^[\x21-\x7E]{16,256}$/.test(password)) { + throw new Error( + "APP_DATABASE_PASSWORD must contain 16 to 256 printable ASCII characters without spaces", + ); + } + const database = new ProductionDatabase(config); + try { + return await database.withSystemTransaction(async (client) => { + const capability = await client.query<{ + is_superuser: boolean; + can_create_roles: boolean; + can_create_databases: boolean; + can_replicate: boolean; + can_bypass_rls: boolean; + target_exists: boolean; + has_admin_option: boolean; + target_is_superuser: boolean; + target_can_create_databases: boolean; + target_bypasses_rls: boolean; + target_is_replication: boolean; + }>( + `SELECT + actor_role.rolsuper AS is_superuser, + actor_role.rolcreaterole AS can_create_roles, + actor_role.rolcreatedb AS can_create_databases, + actor_role.rolreplication AS can_replicate, + actor_role.rolbypassrls AS can_bypass_rls, + EXISTS ( + SELECT 1 + FROM pg_roles target_role + WHERE target_role.rolname = 'agentic_app' + ) AS target_exists, + EXISTS ( + SELECT 1 + FROM pg_auth_members membership + JOIN pg_roles target_role + ON target_role.oid = membership.roleid + WHERE target_role.rolname = 'agentic_app' + AND membership.member = actor_role.oid + AND membership.admin_option + ) AS has_admin_option, + COALESCE(( + SELECT target_role.rolsuper + FROM pg_roles target_role + WHERE target_role.rolname = 'agentic_app' + ), FALSE) AS target_is_superuser, + COALESCE(( + SELECT target_role.rolcreatedb + FROM pg_roles target_role + WHERE target_role.rolname = 'agentic_app' + ), FALSE) AS target_can_create_databases, + COALESCE(( + SELECT target_role.rolbypassrls + FROM pg_roles target_role + WHERE target_role.rolname = 'agentic_app' + ), FALSE) AS target_bypasses_rls, + COALESCE(( + SELECT target_role.rolreplication + FROM pg_roles target_role + WHERE target_role.rolname = 'agentic_app' + ), FALSE) AS target_is_replication + FROM pg_roles actor_role + WHERE actor_role.rolname = current_user`, + ); + const permissions = capability.rows[0]; + if ( + !permissions || + ( + permissions.is_superuser !== true && + permissions.can_create_roles !== true + ) + ) { + throw new Error( + "Runtime role bootstrap requires a PostgreSQL role with CREATEROLE", + ); + } + if ( + permissions.target_exists && + !permissions.is_superuser && + !permissions.has_admin_option + ) { + throw new Error( + "Runtime role bootstrap requires superuser or ADMIN OPTION on agentic_app", + ); + } + if ( + permissions.target_exists && + !permissions.is_superuser && + ( + permissions.target_is_superuser || + ( + permissions.target_can_create_databases && + !permissions.can_create_databases + ) || + ( + permissions.target_bypasses_rls && + !permissions.can_bypass_rls + ) || + ( + permissions.target_is_replication && + !permissions.can_replicate + ) + ) + ) { + throw new Error( + "Runtime role bootstrap requires superuser or matching elevated privileges to restrict agentic_app", + ); + } + const memberships = await client.query<{ role_name: string }>( + `SELECT parent_role.rolname AS role_name + FROM pg_auth_members membership + JOIN pg_roles member_role + ON member_role.oid = membership.member + JOIN pg_roles parent_role + ON parent_role.oid = membership.roleid + WHERE member_role.rolname = 'agentic_app' + ORDER BY parent_role.rolname`, + ); + if (memberships.rows.length > 0) { + throw new Error( + `agentic_app must not belong to other roles: ${memberships.rows + .map((row) => row.role_name) + .join(", ")}`, + ); + } + const members = await client.query<{ member_name: string }>( + `SELECT member_role.rolname AS member_name + FROM pg_auth_members membership + JOIN pg_roles parent_role + ON parent_role.oid = membership.roleid + JOIN pg_roles member_role + ON member_role.oid = membership.member + WHERE parent_role.rolname = 'agentic_app' + AND ( + membership.inherit_option + OR membership.set_option + OR NOT membership.admin_option + ) + ORDER BY member_role.rolname`, + ); + if (members.rows.length > 0) { + throw new Error( + `agentic_app must not have privilege-bearing role members: ${members.rows + .map((row) => row.member_name) + .join(", ")}`, + ); + } + const ownership = await client.query<{ owns_objects: boolean }>( + `SELECT EXISTS ( + SELECT 1 + FROM pg_shdepend dependency + JOIN pg_roles owner_role + ON owner_role.oid = dependency.refobjid + WHERE owner_role.rolname = 'agentic_app' + AND dependency.deptype = 'o' + ) AS owns_objects`, + ); + if (ownership.rows[0]?.owns_objects === true) { + throw new Error( + "agentic_app must not own database objects", + ); + } + const verifier = createScramVerifier(password); + await client.query( + "SELECT set_config('agentic.bootstrap_verifier', $1, true)", + [verifier], + ); + const existing = await client.query<{ exists: boolean }>( + `SELECT EXISTS ( + SELECT 1 FROM pg_roles WHERE rolname = 'agentic_app' + ) AS exists`, + ); + const attributes = [ + "LOGIN", + "NOCREATEROLE", + "NOINHERIT", + ...(permissions.is_superuser ? ["NOSUPERUSER"] : []), + ...(permissions.is_superuser || permissions.can_create_databases + ? ["NOCREATEDB"] + : []), + ...(permissions.is_superuser || permissions.can_replicate + ? ["NOREPLICATION"] + : []), + ...(permissions.is_superuser || permissions.can_bypass_rls + ? ["NOBYPASSRLS"] + : []), + ].join(" "); + await client.query( + `DO $bootstrap$ + BEGIN + IF EXISTS ( + SELECT 1 FROM pg_roles WHERE rolname = 'agentic_app' + ) THEN + EXECUTE format( + 'ALTER ROLE agentic_app WITH ${attributes} PASSWORD %L', + current_setting('agentic.bootstrap_verifier') + ); + ELSE + EXECUTE format( + 'CREATE ROLE agentic_app WITH ${attributes} PASSWORD %L', + current_setting('agentic.bootstrap_verifier') + ); + END IF; + END + $bootstrap$`, + ); + const verified = await client.query<{ + restricted: boolean; + }>( + `SELECT ( + rolcanlogin + AND NOT rolsuper + AND NOT rolcreatedb + AND NOT rolcreaterole + AND NOT rolinherit + AND NOT rolreplication + AND NOT rolbypassrls + AND NOT EXISTS ( + SELECT 1 + FROM pg_auth_members membership + WHERE membership.member = pg_roles.oid + ) + AND NOT EXISTS ( + SELECT 1 + FROM pg_auth_members membership + WHERE membership.roleid = pg_roles.oid + AND ( + membership.inherit_option + OR membership.set_option + OR NOT membership.admin_option + ) + ) + AND NOT EXISTS ( + SELECT 1 + FROM pg_shdepend dependency + WHERE dependency.refobjid = pg_roles.oid + AND dependency.deptype = 'o' + ) + ) AS restricted + FROM pg_roles + WHERE rolname = 'agentic_app'`, + ); + if (verified.rows[0]?.restricted !== true) { + throw new Error( + "Runtime role bootstrap could not enforce restricted agentic_app attributes", + ); + } + return { + role: "agentic_app", + created: existing.rows[0]?.exists !== true, + }; + }); + } finally { + await database.close(); + } +} + +function createScramVerifier(password: string): string { + const salt = randomBytes(16); + const saltedPassword = pbkdf2Sync( + password, + salt, + SCRAM_ITERATIONS, + 32, + "sha256", + ); + const clientKey = createHmac("sha256", saltedPassword) + .update("Client Key") + .digest(); + const storedKey = createHash("sha256").update(clientKey).digest(); + const serverKey = createHmac("sha256", saltedPassword) + .update("Server Key") + .digest(); + return ( + `SCRAM-SHA-256$${SCRAM_ITERATIONS}:${salt.toString("base64")}` + + `$${storedKey.toString("base64")}:${serverKey.toString("base64")}` + ); +} diff --git a/src/production/cli.ts b/src/production/cli.ts index 5ae0c5b..2c37041 100644 --- a/src/production/cli.ts +++ b/src/production/cli.ts @@ -5,6 +5,7 @@ import { createApiKey, revokeApiKey, } from "./auth.js"; +import { bootstrapRuntimeRole } from "./bootstrap.js"; import { formatTraceExplanation, normalizeTraceDepth, @@ -37,6 +38,14 @@ import { createProductionRuntime } from "./runtime.js"; async function main(): Promise { const [command = "help", ...args] = process.argv.slice(2); switch (command) { + case "bootstrap-role": { + const result = await bootstrapRuntimeRole( + loadMigrationDatabaseConfig(), + requiredEnvironment("APP_DATABASE_PASSWORD", 16), + ); + print(result); + return; + } case "migrate": { const embeddingSpace = loadEmbeddingSpaceConfig(); const applied = await migratePostgres( @@ -324,6 +333,7 @@ function print(value: unknown): void { const helpText = `Agentic Data Kernel production profile Commands: + bootstrap-role migrate migration-status create-key --tenant ID --principal ID [--tenant-name NAME] diff --git a/src/production/config.ts b/src/production/config.ts index 9d5fd08..7c52c63 100644 --- a/src/production/config.ts +++ b/src/production/config.ts @@ -7,9 +7,18 @@ import { type EmbeddingSpace, } from "./embeddings.js"; +const optionalEnvironmentValue = z.preprocess( + (value) => + typeof value === "string" && value.trim() === "" + ? undefined + : value, + z.string().min(1).optional(), +); + const baseSchema = z.object({ DATABASE_URL: z.string().url(), DATABASE_SSL: z.enum(["disable", "require"]).default("disable"), + DATABASE_CA_CERT_BASE64: optionalEnvironmentValue, DATABASE_POOL_SIZE: z.coerce.number().int().min(1).max(100).default(20), DATABASE_STATEMENT_TIMEOUT_MS: z.coerce .number() @@ -97,6 +106,7 @@ const serverSchema = baseSchema.extend({ export interface DatabaseConfig { databaseUrl: string; databaseSsl: boolean; + databaseCaCertificate?: string; databasePoolSize: number; statementTimeoutMs: number; } @@ -141,9 +151,13 @@ export function loadDatabaseConfig( environment: NodeJS.ProcessEnv = process.env, ): DatabaseConfig { const parsed = parse(baseSchema, environment); + const databaseCaCertificate = parseDatabaseCaCertificate( + parsed.DATABASE_CA_CERT_BASE64, + ); return { databaseUrl: parsed.DATABASE_URL, databaseSsl: parsed.DATABASE_SSL === "require", + ...(databaseCaCertificate ? { databaseCaCertificate } : {}), databasePoolSize: parsed.DATABASE_POOL_SIZE, statementTimeoutMs: parsed.DATABASE_STATEMENT_TIMEOUT_MS, }; @@ -191,10 +205,14 @@ export function loadProductionConfig( .map((host) => host.trim().toLowerCase()) .filter(Boolean), ); + const databaseCaCertificate = parseDatabaseCaCertificate( + parsed.DATABASE_CA_CERT_BASE64, + ); return { databaseUrl: parsed.DATABASE_URL, databaseSsl: parsed.DATABASE_SSL === "require", + ...(databaseCaCertificate ? { databaseCaCertificate } : {}), databasePoolSize: parsed.DATABASE_POOL_SIZE, statementTimeoutMs: parsed.DATABASE_STATEMENT_TIMEOUT_MS, authPepper: parsed.AUTH_PEPPER, @@ -221,6 +239,24 @@ export function loadProductionConfig( }; } +function parseDatabaseCaCertificate( + encoded: string | undefined, +): string | undefined { + if (!encoded) { + return undefined; + } + const certificate = Buffer.from(encoded, "base64").toString("utf8"); + if ( + !certificate.includes("-----BEGIN CERTIFICATE-----") || + !certificate.includes("-----END CERTIFICATE-----") + ) { + throw new Error( + "DATABASE_CA_CERT_BASE64 must decode to a PEM certificate bundle", + ); + } + return certificate; +} + export function configuredEmbeddingSpace( config: Pick< ProductionConfig, diff --git a/src/production/database.ts b/src/production/database.ts index 98a8e8e..12ebdd2 100644 --- a/src/production/database.ts +++ b/src/production/database.ts @@ -4,6 +4,7 @@ import { type QueryResult, type QueryResultRow, } from "pg"; +import { checkServerIdentity } from "node:tls"; import type { DatabaseConfig } from "./config.js"; export interface TenantContext { @@ -20,13 +21,27 @@ export class ProductionDatabase { public readonly pool: Pool; public constructor(private readonly config: DatabaseConfig) { + const connectionString = validatedConnectionString( + config.databaseUrl, + ); + const databaseHostname = databaseHostnameFor(connectionString); this.pool = new Pool({ - connectionString: config.databaseUrl, + connectionString, max: config.databasePoolSize, idleTimeoutMillis: 30_000, connectionTimeoutMillis: 10_000, application_name: "agentic-data-kernel", - ssl: config.databaseSsl ? { rejectUnauthorized: true } : undefined, + ssl: config.databaseSsl + ? { + rejectUnauthorized: true, + ...(config.databaseCaCertificate + ? { ca: config.databaseCaCertificate } + : {}), + checkServerIdentity: (_hostname, certificate) => + checkServerIdentity(databaseHostname, certificate), + } + : false, + sslnegotiation: "postgres", }); } @@ -149,6 +164,35 @@ export class ProductionDatabase { } } +function validatedConnectionString(value: string): string { + const url = new URL(value); + const forbidden = new Set([ + "ssl", + "sslcert", + "sslkey", + "sslmode", + "sslnegotiation", + "sslrootcert", + "uselibpqcompat", + ]); + const conflicts = [...url.searchParams.keys()].filter((name) => + forbidden.has(name.toLowerCase()), + ); + if (conflicts.length > 0) { + throw new Error( + "DATABASE_URL must not contain SSL query parameters; use DATABASE_SSL and DATABASE_CA_CERT_BASE64", + ); + } + return value; +} + +function databaseHostnameFor(connectionString: string): string { + const hostname = new URL(connectionString).hostname; + return hostname.startsWith("[") && hostname.endsWith("]") + ? hostname.slice(1, -1) + : hostname; +} + export class MaintenanceModeError extends Error { public constructor(public readonly owner: string) { super(`Writes are paused for ${owner}`); diff --git a/src/production/index.ts b/src/production/index.ts index ff3b87a..4178df2 100644 --- a/src/production/index.ts +++ b/src/production/index.ts @@ -7,6 +7,8 @@ export { requireScope, revokeApiKey, } from "./auth.js"; +export { bootstrapRuntimeRole } from "./bootstrap.js"; +export type { RuntimeRoleBootstrapResult } from "./bootstrap.js"; export type { AuthenticatedPrincipal, CreateApiKeyInput, diff --git a/src/production/migrations.ts b/src/production/migrations.ts index 76f9152..c5eb744 100644 --- a/src/production/migrations.ts +++ b/src/production/migrations.ts @@ -71,6 +71,7 @@ export async function migratePostgres( ); newlyApplied.push(file); } + await reconcileRuntimeRolePrivileges(client); return newlyApplied; }); await configureEmbeddingSpace(database, targetSpace); @@ -78,6 +79,55 @@ export async function migratePostgres( } finally { await database.close(); } + + async function reconcileRuntimeRolePrivileges( + client: PoolClient, + ): Promise { + const role = await client.query<{ exists: boolean }>( + `SELECT EXISTS ( + SELECT 1 FROM pg_roles WHERE rolname = 'agentic_app' + ) AS exists`, + ); + if (role.rows[0]?.exists !== true) { + return; + } + await client.query( + `DO $grant$ + BEGIN + EXECUTE format( + 'GRANT CONNECT ON DATABASE %I TO agentic_app', + current_database() + ); + END + $grant$; + GRANT USAGE ON SCHEMA agentic, agentic_auth TO agentic_app; + GRANT SELECT, INSERT, UPDATE, DELETE + ON ALL TABLES IN SCHEMA agentic, agentic_auth + TO agentic_app; + GRANT USAGE, SELECT + ON ALL SEQUENCES IN SCHEMA agentic, agentic_auth + TO agentic_app; + GRANT EXECUTE + ON ALL FUNCTIONS IN SCHEMA agentic + TO agentic_app; + ALTER DEFAULT PRIVILEGES IN SCHEMA agentic + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO agentic_app; + ALTER DEFAULT PRIVILEGES IN SCHEMA agentic_auth + GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO agentic_app; + ALTER DEFAULT PRIVILEGES IN SCHEMA agentic + GRANT USAGE, SELECT ON SEQUENCES TO agentic_app; + ALTER DEFAULT PRIVILEGES IN SCHEMA agentic_auth + GRANT USAGE, SELECT ON SEQUENCES TO agentic_app; + ALTER DEFAULT PRIVILEGES IN SCHEMA agentic + GRANT EXECUTE ON FUNCTIONS TO agentic_app; + REVOKE INSERT, UPDATE, DELETE, TRUNCATE + ON agentic.embedding_configuration + FROM agentic_app; + GRANT SELECT + ON agentic.embedding_configuration + TO agentic_app`, + ); + } } async function assertEmbeddingMigrationCompatible( diff --git a/src/test/production.test.ts b/src/test/production.test.ts index 57df349..58fb906 100644 --- a/src/test/production.test.ts +++ b/src/test/production.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import { createHash, randomUUID } from "node:crypto"; import { mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs"; import type { AddressInfo } from "node:net"; +import type { PeerCertificate } from "node:tls"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { createServer } from "node:http"; @@ -19,9 +20,11 @@ import { revokeApiKey, type AuthenticatedPrincipal, } from "../production/auth.js"; +import { bootstrapRuntimeRole } from "../production/bootstrap.js"; import { reconcileArtifactFiles } from "../production/artifact-reconciliation.js"; import { configuredEmbeddingSpace, + loadDatabaseConfig, loadEmbeddingSpaceConfig, type ProductionConfig, } from "../production/config.js"; @@ -58,6 +61,174 @@ const databaseUrl = process.env.PRODUCTION_TEST_DATABASE_URL; const migrationDatabaseUrl = process.env.PRODUCTION_TEST_MIGRATION_DATABASE_URL; +test( + "runtime role bootstrap is idempotent and preserves restricted attributes", + { skip: !migrationDatabaseUrl }, + async () => { + assert.ok(migrationDatabaseUrl); + const password = + process.env.APP_DATABASE_PASSWORD ?? "ci-application-password"; + const first = await bootstrapRuntimeRole( + { + databaseUrl: migrationDatabaseUrl, + databaseSsl: false, + databasePoolSize: 2, + statementTimeoutMs: 30_000, + }, + password, + ); + assert.equal(first.role, "agentic_app"); + const second = await bootstrapRuntimeRole( + { + databaseUrl: migrationDatabaseUrl, + databaseSsl: false, + databasePoolSize: 2, + statementTimeoutMs: 30_000, + }, + password, + ); + assert.deepEqual(second, { + role: "agentic_app", + created: false, + }); + const appUrl = new URL(migrationDatabaseUrl); + appUrl.username = "agentic_app"; + appUrl.password = password; + const appClient = new PgClient({ connectionString: appUrl.toString() }); + await appClient.connect(); + try { + const identity = await appClient.query<{ role_name: string }>( + "SELECT current_user AS role_name", + ); + assert.equal(identity.rows[0]?.role_name, "agentic_app"); + } finally { + await appClient.end(); + } + const client = new PgClient({ connectionString: migrationDatabaseUrl }); + await client.connect(); + try { + const role = await client.query<{ + rolcanlogin: boolean; + rolsuper: boolean; + rolcreatedb: boolean; + rolcreaterole: boolean; + rolinherit: boolean; + rolbypassrls: boolean; + }>( + `SELECT + rolcanlogin, + rolsuper, + rolcreatedb, + rolcreaterole, + rolinherit, + rolbypassrls + FROM pg_roles + WHERE rolname = 'agentic_app'`, + ); + assert.deepEqual(role.rows[0], { + rolcanlogin: true, + rolsuper: false, + rolcreatedb: false, + rolcreaterole: false, + rolinherit: false, + rolbypassrls: false, + }); + } finally { + await client.end(); + } + }, +); + +test( + "runtime role bootstrap rejects object ownership", + { skip: !migrationDatabaseUrl }, + async () => { + assert.ok(migrationDatabaseUrl); + const schemaName = + `bootstrap_owned_${randomUUID().replaceAll("-", "")}`; + const administrator = new PgClient({ + connectionString: migrationDatabaseUrl, + }); + await administrator.connect(); + try { + await administrator.query( + `CREATE SCHEMA "${schemaName}" AUTHORIZATION agentic_app`, + ); + await assert.rejects( + () => + bootstrapRuntimeRole( + { + databaseUrl: migrationDatabaseUrl, + databaseSsl: false, + databasePoolSize: 2, + statementTimeoutMs: 30_000, + }, + process.env.APP_DATABASE_PASSWORD ?? + "ci-application-password", + ), + /must not own database objects/, + ); + } finally { + await administrator.query( + `DROP SCHEMA IF EXISTS "${schemaName}"`, + ); + await administrator.end(); + } + }, +); + +test( + "runtime role bootstrap requires admin option when the role exists", + { skip: !migrationDatabaseUrl }, + async () => { + assert.ok(migrationDatabaseUrl); + const suffix = randomUUID().replaceAll("-", ""); + const roleName = `bootstrap_limited_${suffix}`; + const rolePassword = `limited-${suffix}`; + const appPassword = + process.env.APP_DATABASE_PASSWORD ?? "ci-application-password"; + const administrator = new PgClient({ + connectionString: migrationDatabaseUrl, + }); + await administrator.connect(); + try { + await administrator.query( + `CREATE ROLE "${roleName}" + LOGIN CREATEROLE PASSWORD '${rolePassword}'`, + ); + const url = new URL(migrationDatabaseUrl); + url.username = roleName; + url.password = rolePassword; + const config = { + databaseUrl: url.toString(), + databaseSsl: false, + databasePoolSize: 2, + statementTimeoutMs: 30_000, + }; + await assert.rejects( + () => bootstrapRuntimeRole(config, appPassword), + /ADMIN OPTION/, + ); + await administrator.query( + `GRANT agentic_app TO "${roleName}" + WITH ADMIN TRUE, INHERIT FALSE, SET FALSE`, + ); + assert.deepEqual( + await bootstrapRuntimeRole(config, appPassword), + { + role: "agentic_app", + created: false, + }, + ); + } finally { + await administrator.query( + `DROP ROLE IF EXISTS "${roleName}"`, + ); + await administrator.end(); + } + }, +); + test("packaged migrations resolve relative to the module", () => { const migrations = readdirSync(postgresMigrationDirectory); assert.ok(migrations.includes("001_core.sql")); @@ -101,6 +272,264 @@ test("embedding dimensions are validated as deployment configuration", () => { ); }); +test("database TLS accepts an explicit base64 PEM trust bundle", async () => { + assert.deepEqual( + loadDatabaseConfig({ + DATABASE_URL: "postgresql://example:password@database.example/test", + DATABASE_CA_CERT_BASE64: "", + }), + { + databaseUrl: + "postgresql://example:password@database.example/test", + databaseSsl: false, + databasePoolSize: 20, + statementTimeoutMs: 30_000, + }, + ); + const certificate = [ + "-----BEGIN CERTIFICATE-----", + "test-certificate", + "-----END CERTIFICATE-----", + ].join("\n"); + assert.deepEqual( + loadDatabaseConfig({ + DATABASE_URL: "postgresql://example:password@database.example/test", + DATABASE_SSL: "require", + DATABASE_CA_CERT_BASE64: + Buffer.from(certificate, "utf8").toString("base64"), + }), + { + databaseUrl: + "postgresql://example:password@database.example/test", + databaseSsl: true, + databaseCaCertificate: certificate, + databasePoolSize: 20, + statementTimeoutMs: 30_000, + }, + ); + assert.throws( + () => + loadDatabaseConfig({ + DATABASE_URL: + "postgresql://example:password@database.example/test", + DATABASE_SSL: "require", + DATABASE_CA_CERT_BASE64: + Buffer.from("not a certificate", "utf8").toString("base64"), + }), + /PEM certificate bundle/, + ); + assert.throws( + () => + new ProductionDatabase({ + databaseUrl: + "postgresql://example:password@database.example/test?sslmode=disable", + databaseSsl: true, + databasePoolSize: 1, + statementTimeoutMs: 30_000, + }), + /must not contain SSL query parameters/, + ); + assert.throws( + () => + new ProductionDatabase({ + databaseUrl: + "postgresql://example:password@database.example/test?sslnegotiation=direct", + databaseSsl: true, + databasePoolSize: 1, + statementTimeoutMs: 30_000, + }), + /must not contain SSL query parameters/, + ); + const database = new ProductionDatabase({ + databaseUrl: + "postgresql://example:password@127.0.0.1/test", + databaseSsl: true, + databasePoolSize: 1, + statementTimeoutMs: 30_000, + }); + try { + const ssl = database.pool.options.ssl; + assert.ok( + ssl !== null && + typeof ssl === "object" && + typeof ssl.checkServerIdentity === "function", + ); + const mismatch = ssl.checkServerIdentity( + "localhost", + { + subject: { CN: "localhost" }, + subjectaltname: "DNS:localhost", + } as PeerCertificate, + ); + assert.ok(mismatch); + assert.match(mismatch.message, /127\.0\.0\.1/); + } finally { + await database.close(); + } +}); + +test( + "runtime role bootstrap rejects inherited role capabilities", + { skip: !migrationDatabaseUrl }, + async () => { + assert.ok(migrationDatabaseUrl); + const roleName = + `bootstrap_parent_${randomUUID().replaceAll("-", "")}`; + const administrator = new PgClient({ + connectionString: migrationDatabaseUrl, + }); + await administrator.connect(); + try { + await administrator.query(`CREATE ROLE "${roleName}" NOLOGIN`); + await administrator.query(`GRANT "${roleName}" TO agentic_app`); + await assert.rejects( + () => + bootstrapRuntimeRole( + { + databaseUrl: migrationDatabaseUrl, + databaseSsl: false, + databasePoolSize: 2, + statementTimeoutMs: 30_000, + }, + process.env.APP_DATABASE_PASSWORD ?? + "ci-application-password", + ), + /must not belong to other roles/, + ); + } finally { + await administrator.query( + `REVOKE "${roleName}" FROM agentic_app`, + ); + await administrator.query(`DROP ROLE IF EXISTS "${roleName}"`); + await administrator.end(); + } + }, +); + +test( + "runtime role bootstrap rejects members of the runtime role", + { skip: !migrationDatabaseUrl }, + async () => { + assert.ok(migrationDatabaseUrl); + const roleName = + `bootstrap_member_${randomUUID().replaceAll("-", "")}`; + const administrator = new PgClient({ + connectionString: migrationDatabaseUrl, + }); + await administrator.connect(); + try { + await administrator.query(`CREATE ROLE "${roleName}" LOGIN`); + await administrator.query(`GRANT agentic_app TO "${roleName}"`); + await assert.rejects( + () => + bootstrapRuntimeRole( + { + databaseUrl: migrationDatabaseUrl, + databaseSsl: false, + databasePoolSize: 2, + statementTimeoutMs: 30_000, + }, + process.env.APP_DATABASE_PASSWORD ?? + "ci-application-password", + ), + /must not have privilege-bearing role members/, + ); + } finally { + await administrator.query( + `REVOKE agentic_app FROM "${roleName}"`, + ); + await administrator.query(`DROP ROLE IF EXISTS "${roleName}"`); + await administrator.end(); + } + }, +); + +test( + "migration reruns reconcile runtime role grants", + { skip: !migrationDatabaseUrl }, + async () => { + assert.ok(migrationDatabaseUrl); + const databaseName = + `agentic_grants_${randomUUID().replaceAll("-", "")}`; + const control = new PgClient({ + connectionString: databaseUrlFor( + migrationDatabaseUrl, + "postgres", + ), + }); + await control.connect(); + try { + await control.query(`CREATE DATABASE ${databaseName}`); + const adminUrl = databaseUrlFor( + migrationDatabaseUrl, + databaseName, + ); + const config = { + databaseUrl: adminUrl, + databaseSsl: false, + databasePoolSize: 2, + statementTimeoutMs: 30_000, + }; + await bootstrapRuntimeRole( + config, + process.env.APP_DATABASE_PASSWORD ?? + "ci-application-password", + ); + await migratePostgres(config); + const database = new ProductionDatabase(config); + try { + await database.query( + `REVOKE CONNECT ON DATABASE "${databaseName}" + FROM PUBLIC, agentic_app`, + ); + await database.query( + "REVOKE SELECT ON agentic.entities FROM agentic_app", + ); + } finally { + await database.close(); + } + await migratePostgres(config); + const verified = new ProductionDatabase(config); + try { + const privileges = await verified.query<{ + database_connect: boolean; + entity_select: boolean; + embedding_insert: boolean; + }>( + `SELECT + has_database_privilege( + 'agentic_app', + current_database(), + 'CONNECT' + ) AS database_connect, + has_table_privilege( + 'agentic_app', + 'agentic.entities', + 'SELECT' + ) AS entity_select, + has_table_privilege( + 'agentic_app', + 'agentic.embedding_configuration', + 'INSERT' + ) AS embedding_insert`, + ); + assert.deepEqual(privileges.rows[0], { + database_connect: true, + entity_select: true, + embedding_insert: false, + }); + } finally { + await verified.close(); + } + } finally { + await control.query( + `DROP DATABASE IF EXISTS ${databaseName} WITH (FORCE)`, + ); + await control.end(); + } + }, +); + test( "embedding migration preserves an existing 1536-dimensional space", { skip: !databaseUrl || !migrationDatabaseUrl }, From 60a8f01c67e4824039039a5563daba7666a1723e Mon Sep 17 00:00:00 2001 From: Jason Doyle Date: Thu, 3 Sep 2026 17:54:29 -0700 Subject: [PATCH 2/2] Refresh deployment benchmark evidence --- benchmarks/sre/results/report.md | 10 +++++----- benchmarks/sre/results/summary.json | 22 +++++++++++----------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/benchmarks/sre/results/report.md b/benchmarks/sre/results/report.md index 8df967d..0f5b7db 100644 --- a/benchmarks/sre/results/report.md +++ b/benchmarks/sre/results/report.md @@ -2,9 +2,9 @@ Generated from `summary.json`. -Source revision: `b3a12a6a066c4de68adad8f376ff235af58b61c7` +Source revision: `35d010cf263bbaf60a3c1d9f70af8daa9cd51b18` -Source hash: `db5052d97f02920144fbdc1a0871048bef4ad0ac6f742b8ff7013dc7af871545` +Source hash: `d4b468552af75056e88deb65104f535d0e60c4b5b0b8a1333e6e3a9458fc1f95` ## Correctness @@ -24,7 +24,7 @@ Both variants must resolve every run with one delivery and one reconciliation. The adapter delegates to the shipped SRE scenario, which contains 929 nonblank TypeScript source lines inside the -dependency. The full kernel dependency contains 13750 +dependency. The full kernel dependency contains 14181 nonblank TypeScript source lines. The benchmark runner and engine-specific audit verification contain @@ -44,8 +44,8 @@ operated, or upgraded. | Variant | Median milliseconds | | --- | ---: | -| Conventional PostgreSQL | 58.12 | -| Agentic Data Kernel | 876.17 | +| Conventional PostgreSQL | 99.08 | +| Agentic Data Kernel | 1132.31 | Runtime is not a headline metric. The variants perform different work and this deterministic smoke benchmark is not a latency study. diff --git a/benchmarks/sre/results/summary.json b/benchmarks/sre/results/summary.json index 540bc7b..17211f5 100644 --- a/benchmarks/sre/results/summary.json +++ b/benchmarks/sre/results/summary.json @@ -4,8 +4,8 @@ "environment": { "node": "v22.22.2", "postgres": "18.6 (Debian 18.6-1.pgdg12+2)", - "commit": "b3a12a6a066c4de68adad8f376ff235af58b61c7", - "sourceHash": "db5052d97f02920144fbdc1a0871048bef4ad0ac6f742b8ff7013dc7af871545" + "commit": "35d010cf263bbaf60a3c1d9f70af8daa9cd51b18", + "sourceHash": "d4b468552af75056e88deb65104f535d0e60c4b5b0b8a1333e6e3a9458fc1f95" }, "runs": [ { @@ -31,7 +31,7 @@ "provider reconciliation": true, "verification and terminal state": true }, - "durationMs": 63.28479999999996 + "durationMs": 207.70100000000002 }, "operatedTables": 8, "databaseBytes": 540672 @@ -59,7 +59,7 @@ "provider reconciliation": true, "verification and terminal state": true }, - "durationMs": 891.0822000000001 + "durationMs": 1001.6923999999999 }, "operatedTables": 18, "databaseBytes": 1572864 @@ -87,7 +87,7 @@ "provider reconciliation": true, "verification and terminal state": true }, - "durationMs": 58.11660000000006 + "durationMs": 61.16439999999966 }, "operatedTables": 8, "databaseBytes": 540672 @@ -115,7 +115,7 @@ "provider reconciliation": true, "verification and terminal state": true }, - "durationMs": 876.1727000000001 + "durationMs": 1132.3120000000004 }, "operatedTables": 18, "databaseBytes": 1572864 @@ -143,7 +143,7 @@ "provider reconciliation": true, "verification and terminal state": true }, - "durationMs": 53.971000000000004 + "durationMs": 99.08329999999933 }, "operatedTables": 8, "databaseBytes": 540672 @@ -171,7 +171,7 @@ "provider reconciliation": true, "verification and terminal state": true }, - "durationMs": 830.5592000000001 + "durationMs": 1252.8352999999997 }, "operatedTables": 18, "databaseBytes": 1572864 @@ -266,7 +266,7 @@ "authoredTables": 0, "operatedTables": 18, "scenarioSourceLines": 929, - "dependencySourceLines": 13750 + "dependencySourceLines": 14181 } }, "benchmarkHarness": { @@ -277,8 +277,8 @@ "agenticDataKernelMedian": 1572864 }, "runtimeMillisecondsInformational": { - "conventionalPostgresMedian": 58.11660000000006, - "agenticDataKernelMedian": 876.1727000000001 + "conventionalPostgresMedian": 99.08329999999933, + "agenticDataKernelMedian": 1132.3120000000004 }, "explanationQuestions": 9, "claims": {