unoverse 0.1.69 → 0.1.71
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/unoverse.mjs +1 -1
- package/lib/create/credential.mjs +90 -0
- package/lib/create/download.mjs +19 -0
- package/lib/create/index.mjs +141 -0
- package/lib/create/target.mjs +40 -0
- package/lib/ui.mjs +119 -0
- package/lib/urls.mjs +1 -7
- package/operator/infra/aws/main.tf +639 -0
- package/operator/infra/aws/outputs.tf +139 -0
- package/operator/infra/aws/pretoken/index.mjs +37 -0
- package/operator/infra/aws/prices.tf +53 -0
- package/operator/infra/aws/terraform.tfvars.example +55 -0
- package/operator/infra/aws/variables.tf +92 -0
- package/operator/infra/digitalocean/main.tf +297 -0
- package/operator/infra/digitalocean/outputs.tf +96 -0
- package/operator/infra/digitalocean/prices.tf +49 -0
- package/operator/infra/digitalocean/terraform.tfvars.example +52 -0
- package/operator/infra/digitalocean/variables.tf +125 -0
- package/operator/lib/deploy.sh +151 -160
- package/operator/lib/init.sh +2 -9
- package/operator/operator.sh +17 -0
- package/package.json +1 -1
- package/lib/create.mjs +0 -369
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# Everything .env.production needs — the rendered file is COMPLETE, nothing
|
|
2
|
+
# is hand-edited afterwards:
|
|
3
|
+
# terraform output -raw env_production > ../../.env.production
|
|
4
|
+
# unoverse deploy && unoverse deploy db && unoverse deploy test
|
|
5
|
+
|
|
6
|
+
output "deploy_host" {
|
|
7
|
+
description = ".env.production DEPLOY_HOST (SSH target). Public traffic goes to the ALB, never here."
|
|
8
|
+
value = aws_eip.app.public_ip
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
output "alb_dns_name" {
|
|
12
|
+
description = "Point api.<domain> (and unoverse.<domain> when canvas_public) at this — CNAME/ALIAS. Created automatically when route53_zone_id is set."
|
|
13
|
+
value = aws_lb.public.dns_name
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
output "acm_validation_records" {
|
|
17
|
+
description = "External DNS only (domain set, route53_zone_id empty): create these CNAMEs so the certificate can issue."
|
|
18
|
+
value = (local.has_domain && !local.dns_auto) ? [
|
|
19
|
+
for dvo in aws_acm_certificate.public[0].domain_validation_options : {
|
|
20
|
+
name = dvo.resource_record_name, type = dvo.resource_record_type, value = dvo.resource_record_value
|
|
21
|
+
}
|
|
22
|
+
] : []
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
output "canvas_url" {
|
|
26
|
+
description = "Public Canvas URL (canvas_public = true only) — add it to the client origins."
|
|
27
|
+
value = var.canvas_public ? (local.has_domain ? "https://unoverse.${var.domain}" : "http://${aws_lb.public.dns_name}:3001") : "canvas is admin-only (direct http://<instance-ip>:3001 from admin_cidr)"
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
output "api_url" {
|
|
31
|
+
description = "The API base URL as deployed — https://api.<domain> with a domain, http://<alb-dns> without one."
|
|
32
|
+
value = local.has_domain ? "https://${local.api_host}" : "http://${aws_lb.public.dns_name}"
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
output "database_url" {
|
|
36
|
+
description = ".env.production DATABASE_URL (sslmode=require: RDS enforces TLS by default on PG16 — rds.force_ssl=1 — so the client must ask for it)"
|
|
37
|
+
value = "postgresql://${aws_db_instance.postgres.username}:${random_password.db.result}@${aws_db_instance.postgres.address}:5432/${aws_db_instance.postgres.db_name}?sslmode=require"
|
|
38
|
+
sensitive = true
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
output "redis_host" {
|
|
42
|
+
description = ".env.production REDIS_HOST (REDIS_PORT=6379, REDIS_TLS=true)"
|
|
43
|
+
value = aws_elasticache_replication_group.redis.primary_endpoint_address
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
output "redis_password" {
|
|
47
|
+
description = ".env.production REDIS_PASSWORD"
|
|
48
|
+
value = random_password.redis.result
|
|
49
|
+
sensitive = true
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
output "auth_issuer" {
|
|
53
|
+
description = ".env.production AUTH_ISSUER (the Cognito user pool issuer)"
|
|
54
|
+
value = "https://cognito-idp.${var.region}.amazonaws.com/${aws_cognito_user_pool.pool.id}"
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
output "auth_client_id" {
|
|
58
|
+
description = ".env.production AUTH_CLIENT_ID — and the AUTH_AUDIENCE candidate: Cognito access tokens carry client_id, not aud. SMOKE-TEST audience validation with a real token before declaring the universe up (AWS_DEPLOYMENT.md)."
|
|
59
|
+
value = aws_cognito_user_pool_client.spa.id
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
output "roles" {
|
|
63
|
+
description = "Every RBAC role provisioned as a Cognito group (platform permissions + var.roles)"
|
|
64
|
+
value = sort(keys(aws_cognito_user_group.roles))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
output "cognito_hosted_ui" {
|
|
68
|
+
description = "Hosted login domain (Managed Login)"
|
|
69
|
+
value = "https://${aws_cognito_user_pool_domain.domain.domain}.auth.${var.region}.amazoncognito.com"
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
output "bedrock_access_key_id" {
|
|
73
|
+
description = "Store as a platform awsCredential (accessKeyId) for the Bedrock/Nova nodes"
|
|
74
|
+
value = aws_iam_access_key.bedrock.id
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
output "bedrock_secret_access_key" {
|
|
78
|
+
description = "Store as a platform awsCredential (secretAccessKey)"
|
|
79
|
+
value = aws_iam_access_key.bedrock.secret
|
|
80
|
+
sensitive = true
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
# ── The whole .env.production, rendered COMPLETE ──────────────────────────────
|
|
84
|
+
output "env_production" {
|
|
85
|
+
sensitive = true
|
|
86
|
+
description = "Rendered .env.production — complete, write to a file and deploy"
|
|
87
|
+
value = <<-ENV
|
|
88
|
+
# UNIVERSE (AWS) — generated by infra/aws. COMPLETE:
|
|
89
|
+
# do not hand-edit; change terraform.tfvars and re-render instead.
|
|
90
|
+
|
|
91
|
+
# Deploy target (SSH; public traffic goes through the ALB)
|
|
92
|
+
DEPLOY_HOST=${aws_eip.app.public_ip}
|
|
93
|
+
DEPLOY_USER=ubuntu
|
|
94
|
+
|
|
95
|
+
# Container registry
|
|
96
|
+
DOCR_TOKEN=${var.docr_token}
|
|
97
|
+
|
|
98
|
+
# Database (RDS enforces TLS on PG16 — sslmode stays). AWS runs DIRECT (no
|
|
99
|
+
# pooler until `large` per the Postgres law): both URLs are the same server.
|
|
100
|
+
DATABASE_URL=postgresql://${aws_db_instance.postgres.username}:${random_password.db.result}@${aws_db_instance.postgres.address}:5432/${aws_db_instance.postgres.db_name}?sslmode=require
|
|
101
|
+
DATABASE_URL_DIRECT=postgresql://${aws_db_instance.postgres.username}:${random_password.db.result}@${aws_db_instance.postgres.address}:5432/${aws_db_instance.postgres.db_name}?sslmode=require
|
|
102
|
+
|
|
103
|
+
# Connection budget (INFRASTRUCTURE.md, size = ${var.size})
|
|
104
|
+
DB_POOL_ENGINE=${local.s.pool_engine}
|
|
105
|
+
DB_POOL_ENGINE_LEGACY=${local.s.pool_legacy}
|
|
106
|
+
DB_POOL_MEMORY=${local.s.pool_memory}
|
|
107
|
+
|
|
108
|
+
# Credential encryption at rest — per-deployment, generated by Terraform.
|
|
109
|
+
# Back this up with the database (a DB backup is unreadable without it).
|
|
110
|
+
CREDENTIAL_ENCRYPTION_KEY=${random_password.credential_key.result}
|
|
111
|
+
|
|
112
|
+
# Redis (ElastiCache, TLS + auth token)
|
|
113
|
+
REDIS_HOST=${aws_elasticache_replication_group.redis.primary_endpoint_address}
|
|
114
|
+
REDIS_PORT=6379
|
|
115
|
+
REDIS_PASSWORD=${random_password.redis.result}
|
|
116
|
+
REDIS_TLS=true
|
|
117
|
+
REDIS_NAMESPACE=universe
|
|
118
|
+
|
|
119
|
+
# OIDC (Cognito). AUTH_AUDIENCE = the app client id — Cognito access tokens
|
|
120
|
+
# carry client_id, not aud; SMOKE-TEST with a real token (AWS_DEPLOYMENT.md).
|
|
121
|
+
AUTH_ISSUER=https://cognito-idp.${var.region}.amazonaws.com/${aws_cognito_user_pool.pool.id}
|
|
122
|
+
AUTH_CLIENT_ID=${aws_cognito_user_pool_client.spa.id}
|
|
123
|
+
AUTH_AUDIENCE=${aws_cognito_user_pool_client.spa.id}
|
|
124
|
+
|
|
125
|
+
# Service keys
|
|
126
|
+
OPENAI_API_KEY=${var.openai_api_key}
|
|
127
|
+
HYPERBROWSER_API_KEY=${var.hyperbrowser_api_key}
|
|
128
|
+
|
|
129
|
+
# Ingress. With a domain: DOMAIN drives every URL (compose derives
|
|
130
|
+
# https://api.<domain>). Without one (POC): DOMAIN stays empty and the
|
|
131
|
+
# explicit URLs below point at the ALB's DNS name over plain HTTP.
|
|
132
|
+
# To upgrade later: set domain in terraform.tfvars, terraform apply,
|
|
133
|
+
# re-render this file, unoverse deploy. Nothing is destroyed.
|
|
134
|
+
DOMAIN=${var.domain}
|
|
135
|
+
${local.has_domain ? "" : "API_URL=http://${aws_lb.public.dns_name}"}
|
|
136
|
+
${local.has_domain ? "" : "VITE_SERVER_WS_URL=ws://${aws_lb.public.dns_name}"}
|
|
137
|
+
${local.has_domain ? "" : "UNOVERSE_URL=http://${aws_lb.public.dns_name}"}
|
|
138
|
+
ENV
|
|
139
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cognito Pre Token Generation (V2_0) — the platform's token contract.
|
|
3
|
+
*
|
|
4
|
+
* LOAD-BEARING. The platform reads sub/email/roles (and permissions) off the
|
|
5
|
+
* ACCESS token and never calls back to the IdP (docs/AUTH_TOKEN_FLOW.md). Without
|
|
6
|
+
* this Lambda, email-keyed features silently no-op and no role-gated surface
|
|
7
|
+
* (workflow:author builder, marketplace:publish publish, requires.role nodes)
|
|
8
|
+
* can ever pass. The Cognito equivalent of the Auth0 Post-Login Action.
|
|
9
|
+
*
|
|
10
|
+
* Roles = the user's Cognito groups, verbatim. Groups are named in the platform's
|
|
11
|
+
* noun:verb grammar (workflow:author, marketplace:publish, finance:approve, ...),
|
|
12
|
+
* so group membership IS the RBAC surface. They are emitted on BOTH `roles` and
|
|
13
|
+
* `permissions`: the platform's builder/publish gates read permissions, node
|
|
14
|
+
* requires.role matches either.
|
|
15
|
+
*/
|
|
16
|
+
export const handler = async (event) => {
|
|
17
|
+
const groups = event.request.groupConfiguration?.groupsToOverride ?? [];
|
|
18
|
+
const email = event.request.userAttributes?.email;
|
|
19
|
+
|
|
20
|
+
const claims = {
|
|
21
|
+
roles: groups,
|
|
22
|
+
permissions: groups,
|
|
23
|
+
...(email ? { email } : {}),
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
event.response = {
|
|
27
|
+
claimsAndScopeOverrideDetails: {
|
|
28
|
+
accessTokenGeneration: {
|
|
29
|
+
claimsToAddOrOverride: claims,
|
|
30
|
+
},
|
|
31
|
+
idTokenGeneration: {
|
|
32
|
+
claimsToAddOrOverride: claims,
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
return event;
|
|
37
|
+
};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# What this ground costs to run, priced where its sizes are chosen.
|
|
2
|
+
#
|
|
3
|
+
# ONE HOME FOR THE NUMBERS, the same arrangement as the DigitalOcean ground: the ground
|
|
4
|
+
# owns its sizes, so the ground prices them. `unoverse deploy` reads this map out of the
|
|
5
|
+
# plan JSON instead of carrying a second table, which is how the CLI came to quote
|
|
6
|
+
# t3-series rates for a ground that had moved to t4g and silently price its database at
|
|
7
|
+
# nothing at all.
|
|
8
|
+
#
|
|
9
|
+
# NOT A COST ORACLE. On-demand rates in a mid-priced region, so a developer sees an order
|
|
10
|
+
# of magnitude instead of a surprise. Rates move and vary by region; the bill is AWS's.
|
|
11
|
+
|
|
12
|
+
locals {
|
|
13
|
+
# Monthly USD, keyed by the instance type it prices. A type absent here is simply not
|
|
14
|
+
# priced (the summary shows the resource with no figure) rather than guessed at.
|
|
15
|
+
prices = {
|
|
16
|
+
# EC2
|
|
17
|
+
"t3.xlarge" = 120 # small: the POC box
|
|
18
|
+
"m6i.2xlarge" = 280 # medium
|
|
19
|
+
"r6i.2xlarge" = 365 # large
|
|
20
|
+
# RDS Postgres
|
|
21
|
+
"db.t4g.small" = 25
|
|
22
|
+
"db.t4g.medium" = 50
|
|
23
|
+
"db.m6g.large" = 125
|
|
24
|
+
# ElastiCache Redis
|
|
25
|
+
"cache.t4g.micro" = 12
|
|
26
|
+
"cache.t4g.small" = 25
|
|
27
|
+
"cache.t4g.medium" = 50
|
|
28
|
+
# Fixed-price resources, keyed by what they are rather than a size
|
|
29
|
+
"alb" = 18
|
|
30
|
+
# Not a size: EBS, the public IPv4 charge, Route 53 and egress at POC traffic. Small
|
|
31
|
+
# and unavoidable, so the estimate carries it rather than reading low by a sixth.
|
|
32
|
+
"baseline" = 15
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
# The steady-state bill for THIS universe, as configured.
|
|
36
|
+
monthly_estimate = sum([
|
|
37
|
+
lookup(local.prices, local.s.instance, 0),
|
|
38
|
+
lookup(local.prices, local.s.pg, 0),
|
|
39
|
+
lookup(local.prices, local.s.redis, 0),
|
|
40
|
+
local.prices["alb"],
|
|
41
|
+
local.prices["baseline"],
|
|
42
|
+
])
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
output "prices" {
|
|
46
|
+
description = "Monthly USD by instance type. The CLI's plan summary reads this, so the deploy screen and the docs cannot drift apart."
|
|
47
|
+
value = local.prices
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
output "monthly_estimate" {
|
|
51
|
+
description = "Roughly what this universe costs a month at on-demand prices, for the size configured."
|
|
52
|
+
value = local.monthly_estimate
|
|
53
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# Copy to terraform.tfvars and fill in — or let `./unoverse ground aws` write it
|
|
2
|
+
# for you, prefilled from the AWS CLI (region, key pair, Route53 zone, your IP).
|
|
3
|
+
# terraform.tfvars is gitignored.
|
|
4
|
+
|
|
5
|
+
region = "us-east-1"
|
|
6
|
+
name = "universe-poc"
|
|
7
|
+
admin_cidr = "203.0.113.7/32" # YOUR IP — the only SSH source
|
|
8
|
+
ssh_key_name = "your-ec2-keypair-name" # must already exist in the region
|
|
9
|
+
admin_email = "you@example.com" # initial admin (Cognito user, all roles; invite emailed)
|
|
10
|
+
size = "small" # small (POC) | medium | large
|
|
11
|
+
# Domain is OPTIONAL — the same law on every ground. Leave it empty and the
|
|
12
|
+
# universe comes up on the ALB's DNS name over plain HTTP (terraform output
|
|
13
|
+
# api_url shows the address). Fill it in later and `terraform apply` again:
|
|
14
|
+
# the SAME deployment upgrades in place to TLS at api.<domain>.
|
|
15
|
+
domain = "" # empty = HTTP on the ALB DNS name; "yourdomain.com" = TLS at api.yourdomain.com
|
|
16
|
+
|
|
17
|
+
# OPTIONAL (with a domain): the domain's Route53 hosted zone id — set it and Terraform creates the
|
|
18
|
+
# DNS records AND auto-validates the certificate. Empty = you add the printed
|
|
19
|
+
# CNAMEs/aliases yourself (outputs: acm_validation_records, alb_dns_name).
|
|
20
|
+
# route53_zone_id = "Z0123456789ABC"
|
|
21
|
+
# (Domain hosted elsewhere — GoDaddy etc.? That's fine on AWS: leave this unset
|
|
22
|
+
# and paste the printed CNAMEs into your DNS panel. No Route53 required.)
|
|
23
|
+
|
|
24
|
+
# POC ONLY: public Canvas at https://unoverse.<domain> — a host rule on the ONE
|
|
25
|
+
# ALB (no second LB; ALBs host-route, unlike DO). Add the URL to oauth origins.
|
|
26
|
+
canvas_public = true
|
|
27
|
+
|
|
28
|
+
# Service secrets — with these set, the rendered .env.production is COMPLETE.
|
|
29
|
+
docr_token = "dop_v1_..." # registry token (pulls platform images)
|
|
30
|
+
openai_api_key = "sk-..." # memory server + OpenAI nodes
|
|
31
|
+
# hyperbrowser_api_key = "" # optional — page intelligence
|
|
32
|
+
|
|
33
|
+
oauth_callback_urls = [
|
|
34
|
+
"https://yourdomain.com",
|
|
35
|
+
"http://localhost:5173", # local Studio/Canvas dev against this universe
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
# RBAC roles (noun:verb — the grammar node manifests' requires.role matches).
|
|
39
|
+
# Each becomes a Cognito group; put a user in the group and the role rides their
|
|
40
|
+
# token. THE FULL SET, mirroring the Auth0 tenant's Gravity API permissions
|
|
41
|
+
# (2026-07-28). The first two are enforced by code gates and are always created
|
|
42
|
+
# even if removed from this list; the last two are declared intent (no code gate
|
|
43
|
+
# yet — enforcement arrives with the surfaces they name).
|
|
44
|
+
roles = [
|
|
45
|
+
"workflow:author", # build and test workflows (builder gate — ENFORCED)
|
|
46
|
+
"marketplace:publish", # publish assets to this universe (publish gate — ENFORCED)
|
|
47
|
+
"workflow:promote", # promote a draft to active / go-live (declared only)
|
|
48
|
+
"admin:access", # admin to Unoverse Canvas (declared only)
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
# The rendered output (terraform output -raw env_production > ../../.env.production)
|
|
52
|
+
# IS your universe's secrets, including the MASTER KEY (CREDENTIAL_ENCRYPTION_KEY)
|
|
53
|
+
# that locks every credential users save in the platform. If it's lost, no backup
|
|
54
|
+
# can bring them back. Never commit it; keep a safe copy with your database backups.
|
|
55
|
+
# To change any value: edit this file and re-render. Never hand-edit the output.
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
variable "region" {
|
|
2
|
+
description = "AWS region. Must have Bedrock model access enabled for the models you use."
|
|
3
|
+
type = string
|
|
4
|
+
default = "us-east-1"
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
variable "name" {
|
|
8
|
+
description = "Prefix for every resource (also the Cognito domain prefix, so keep it dns-safe)."
|
|
9
|
+
type = string
|
|
10
|
+
default = "universe-poc"
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
variable "admin_cidr" {
|
|
14
|
+
description = "The ONLY CIDR allowed to SSH (deploys + ./unoverse key). Your IP as x.x.x.x/32."
|
|
15
|
+
type = string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
variable "ssh_key_name" {
|
|
19
|
+
description = "Name of an existing EC2 key pair for SSH access."
|
|
20
|
+
type = string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
variable "oauth_callback_urls" {
|
|
24
|
+
description = "OIDC redirect URLs for the SPA client (Canvas/Studio origins, e.g. https://yourdomain.com and http://localhost:5173 for dev)."
|
|
25
|
+
type = list(string)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
variable "admin_email" {
|
|
29
|
+
description = "The initial ADMIN user's email. Created in the Cognito pool and placed in every role group (Cognito emails an invite with a temporary password). This is the platform admin — a Cognito user, not an IAM one."
|
|
30
|
+
type = string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
variable "roles" {
|
|
34
|
+
description = "This deployment's RBAC roles, always noun:verb (matched by node manifests' requires.role). Each becomes a Cognito group; membership rides the token's roles/permissions claims. The two platform permissions (workflow:author, marketplace:publish) are always created and need not be listed."
|
|
35
|
+
type = list(string)
|
|
36
|
+
default = []
|
|
37
|
+
validation {
|
|
38
|
+
condition = alltrue([for r in var.roles : can(regex("^[a-z][a-z0-9_-]*:[a-z][a-z0-9_-]*$", r))])
|
|
39
|
+
error_message = "Every role must be noun:verb (lowercase), e.g. finance:approve — the same grammar the node linter enforces."
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
# ── Added 2026-07-29: parity with infra/digitalocean ──────────────────────────
|
|
44
|
+
|
|
45
|
+
variable "size" {
|
|
46
|
+
description = "Deployment size (INFRASTRUCTURE.md size table): small = POC box."
|
|
47
|
+
type = string
|
|
48
|
+
default = "small"
|
|
49
|
+
validation {
|
|
50
|
+
condition = contains(["small", "medium", "large"], var.size)
|
|
51
|
+
error_message = "size must be small, medium or large."
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
variable "domain" {
|
|
56
|
+
description = "OPTIONAL — the same law on every ground. Empty (the default) = no certificate and no hostnames: the ALB serves plain HTTP on its DNS name (terraform output api_url) — the zero-friction first apply. Set it later and re-apply to upgrade IN PLACE to TLS at api.<domain> (and unoverse.<domain> when canvas_public). Nothing is destroyed by the upgrade."
|
|
57
|
+
type = string
|
|
58
|
+
default = ""
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
variable "route53_zone_id" {
|
|
62
|
+
description = "OPTIONAL: the domain's Route53 hosted zone id. Set = Terraform creates the DNS records AND auto-validates the ACM cert. Empty = you create the validation CNAME + A records yourself (printed as outputs)."
|
|
63
|
+
type = string
|
|
64
|
+
default = ""
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
variable "canvas_public" {
|
|
68
|
+
description = "POC ONLY: Canvas publicly at https://unoverse.<domain> — a HOST RULE on the one ALB (ALBs host-route; no second LB needed, unlike DO). false = Canvas stays admin-only direct :3001 (the standing posture). Add https://unoverse.<domain> to the client origins."
|
|
69
|
+
type = bool
|
|
70
|
+
default = false
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
# Service secrets — taken here so the rendered .env.production is COMPLETE;
|
|
74
|
+
# the operator fills terraform.tfvars once and never hand-edits the env file.
|
|
75
|
+
variable "docr_token" {
|
|
76
|
+
description = "Container registry token — pulls the platform images."
|
|
77
|
+
type = string
|
|
78
|
+
sensitive = true
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
variable "openai_api_key" {
|
|
82
|
+
description = "OpenAI API key — memory server + OpenAI nodes."
|
|
83
|
+
type = string
|
|
84
|
+
sensitive = true
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
variable "hyperbrowser_api_key" {
|
|
88
|
+
description = "Optional — page-intelligence features. Empty = feature off."
|
|
89
|
+
type = string
|
|
90
|
+
default = ""
|
|
91
|
+
sensitive = true
|
|
92
|
+
}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
# The Universe — DigitalOcean ground (docs/architecture/INFRASTRUCTURE.md)
|
|
2
|
+
#
|
|
3
|
+
# Same five-input contract as infra/aws, DO implementation: Droplet + Managed
|
|
4
|
+
# Postgres (fronted by its built-in PgBouncer, per the Postgres law) + Managed
|
|
5
|
+
# Redis + DO Load Balancer with a managed Let's Encrypt cert (native ingress —
|
|
6
|
+
# Caddy is retired) + cloud firewall (the ground owns the firewall; Docker
|
|
7
|
+
# cannot bypass what never reaches the box). Terraform provisions and renders
|
|
8
|
+
# .env.production; `unoverse deploy` does the rest.
|
|
9
|
+
|
|
10
|
+
terraform {
|
|
11
|
+
required_version = ">= 1.5"
|
|
12
|
+
required_providers {
|
|
13
|
+
digitalocean = { source = "digitalocean/digitalocean", version = "~> 2.40" }
|
|
14
|
+
random = { source = "hashicorp/random", version = "~> 3.6" }
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
provider "digitalocean" {
|
|
19
|
+
# Empty token falls through to the DIGITALOCEAN_TOKEN env var.
|
|
20
|
+
token = var.do_token != "" ? var.do_token : null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
# ── Size table (INFRASTRUCTURE.md § Sizes — the numbers live in the contract) ──
|
|
24
|
+
locals {
|
|
25
|
+
sizes = {
|
|
26
|
+
small = {
|
|
27
|
+
droplet = "s-4vcpu-16gb-amd" # 4 vCPU / 16 GB — the POC box
|
|
28
|
+
pg = "db-s-1vcpu-1gb" # ~22 backend connections (~19 usable) — hence the pool
|
|
29
|
+
redis = "db-s-1vcpu-1gb"
|
|
30
|
+
# Connection budget: engine 8 / legacy 4 / memory 4 (+2 ops, +1 reserve = 19)
|
|
31
|
+
pool_engine = 8
|
|
32
|
+
pool_legacy = 4
|
|
33
|
+
pool_memory = 4
|
|
34
|
+
pgbouncer = 17 # backend connections the managed pool holds (leaves 5 direct: migrations + ops)
|
|
35
|
+
}
|
|
36
|
+
medium = {
|
|
37
|
+
droplet = "g-8vcpu-32gb"
|
|
38
|
+
pg = "db-s-2vcpu-4gb"
|
|
39
|
+
redis = "db-s-1vcpu-2gb"
|
|
40
|
+
pool_engine = 20
|
|
41
|
+
pool_legacy = 8
|
|
42
|
+
pool_memory = 10
|
|
43
|
+
pgbouncer = 40
|
|
44
|
+
}
|
|
45
|
+
large = {
|
|
46
|
+
droplet = "m-8vcpu-64gb" # memory-optimized: the engine is ONE event loop — RAM, not cores
|
|
47
|
+
pg = "db-s-4vcpu-8gb"
|
|
48
|
+
redis = "db-s-2vcpu-4gb"
|
|
49
|
+
pool_engine = 40
|
|
50
|
+
pool_legacy = 12
|
|
51
|
+
pool_memory = 20
|
|
52
|
+
pgbouncer = 80
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
s = local.sizes[var.size]
|
|
56
|
+
|
|
57
|
+
# Postgres modes (variables.tf): external URL > existing DO cluster > provision.
|
|
58
|
+
pg_external = var.byo_postgres_url != ""
|
|
59
|
+
pg_adopt = !local.pg_external && var.existing_pg_cluster_name != ""
|
|
60
|
+
provision_pg = !local.pg_external && !local.pg_adopt
|
|
61
|
+
pg_managed = local.pg_adopt || local.provision_pg # any DO-managed mode
|
|
62
|
+
|
|
63
|
+
# Domainless-first (DECIDED 2026-07-31): no domain = no cert, the LB speaks
|
|
64
|
+
# plain HTTP on its IP. Setting domain later and re-applying upgrades the SAME
|
|
65
|
+
# LB in place — cert issued, HTTPS rules swap in, HTTP redirect on.
|
|
66
|
+
has_domain = var.domain != ""
|
|
67
|
+
api_host = "api.${var.domain}"
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
# The adopted cluster's facts (id/host/port) when reusing an existing one.
|
|
71
|
+
data "digitalocean_database_cluster" "existing_pg" {
|
|
72
|
+
count = local.pg_adopt ? 1 : 0
|
|
73
|
+
name = var.existing_pg_cluster_name
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
locals {
|
|
77
|
+
# The cluster every universe resource (db/user/pool/firewall) attaches to.
|
|
78
|
+
pg_cluster_id = local.pg_adopt ? data.digitalocean_database_cluster.existing_pg[0].id : (local.provision_pg ? digitalocean_database_cluster.pg[0].id : "")
|
|
79
|
+
pg_cluster_host = local.pg_adopt ? data.digitalocean_database_cluster.existing_pg[0].private_host : (local.provision_pg ? digitalocean_database_cluster.pg[0].private_host : "")
|
|
80
|
+
pg_cluster_port = local.pg_adopt ? data.digitalocean_database_cluster.existing_pg[0].port : (local.provision_pg ? digitalocean_database_cluster.pg[0].port : 25060)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
data "digitalocean_ssh_key" "operator" {
|
|
84
|
+
name = var.ssh_key_name
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
# ── The box ───────────────────────────────────────────────────────────────────
|
|
88
|
+
resource "digitalocean_droplet" "app" {
|
|
89
|
+
name = "${var.name}-app"
|
|
90
|
+
region = var.region
|
|
91
|
+
size = local.s.droplet
|
|
92
|
+
image = "ubuntu-22-04-x64"
|
|
93
|
+
ssh_keys = [data.digitalocean_ssh_key.operator.id]
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
# ── Firewall: the ground owns it (harden.yml's ufw could be bypassed by
|
|
97
|
+
# Docker's iptables rules; a cloud firewall cannot) ────────────────────────────
|
|
98
|
+
resource "digitalocean_firewall" "app" {
|
|
99
|
+
name = "${var.name}-app"
|
|
100
|
+
droplet_ids = [digitalocean_droplet.app.id]
|
|
101
|
+
|
|
102
|
+
inbound_rule {
|
|
103
|
+
protocol = "tcp"
|
|
104
|
+
port_range = "22"
|
|
105
|
+
source_addresses = [var.admin_cidr]
|
|
106
|
+
}
|
|
107
|
+
# Dozzle log viewer — POC size ships it ON, admin-only (INFRASTRUCTURE.md size table).
|
|
108
|
+
inbound_rule {
|
|
109
|
+
protocol = "tcp"
|
|
110
|
+
port_range = "8080"
|
|
111
|
+
source_addresses = [var.admin_cidr]
|
|
112
|
+
}
|
|
113
|
+
# The public surface arrives ONLY through the load balancer.
|
|
114
|
+
inbound_rule {
|
|
115
|
+
protocol = "tcp"
|
|
116
|
+
port_range = "4105"
|
|
117
|
+
source_load_balancer_uids = [digitalocean_loadbalancer.public.id]
|
|
118
|
+
}
|
|
119
|
+
# Canvas (operator UI): the DO LB has NO host-based routing (Caddy did), so the
|
|
120
|
+
# public LB carries ONLY the api host → :4105. Canvas is an OPERATOR tool —
|
|
121
|
+
# admin-only direct access, same trust ring as SSH and Dozzle. It still calls
|
|
122
|
+
# the platform at https://api.<domain> like any client.
|
|
123
|
+
# POC (canvas_public = true): :3001 ALSO accepts the public LB, which serves
|
|
124
|
+
# Canvas at https://api.<domain>:3001. Direct-IP access stays admin-only either way.
|
|
125
|
+
inbound_rule {
|
|
126
|
+
protocol = "tcp"
|
|
127
|
+
port_range = "3001"
|
|
128
|
+
source_addresses = [var.admin_cidr]
|
|
129
|
+
source_load_balancer_uids = var.canvas_public ? [digitalocean_loadbalancer.public.id] : []
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
outbound_rule {
|
|
133
|
+
protocol = "tcp"
|
|
134
|
+
port_range = "1-65535"
|
|
135
|
+
destination_addresses = ["0.0.0.0/0", "::/0"]
|
|
136
|
+
}
|
|
137
|
+
outbound_rule {
|
|
138
|
+
protocol = "udp"
|
|
139
|
+
port_range = "1-65535"
|
|
140
|
+
destination_addresses = ["0.0.0.0/0", "::/0"]
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
# ── Native ingress: DO LB + managed Let's Encrypt cert (Caddy retired) ────────
|
|
145
|
+
# NOTE (INFRASTRUCTURE.md § Ingress): DO caps http idle timeout at 600s — set to
|
|
146
|
+
# the cap; long-quiet SSE/WS sessions must survive a reconnect (smoke-test item).
|
|
147
|
+
resource "digitalocean_certificate" "api" {
|
|
148
|
+
count = local.has_domain ? 1 : 0
|
|
149
|
+
name = "${var.name}-api"
|
|
150
|
+
type = "lets_encrypt"
|
|
151
|
+
# canvas.<domain> rides the SAME certificate as a SAN (and the same LB): the
|
|
152
|
+
# clean hostname costs nothing — only the PORT cannot be dropped, because DO
|
|
153
|
+
# LBs cannot host-route (DECIDED 2026-07-29: one LB, port in the URL).
|
|
154
|
+
domains = var.canvas_public ? [local.api_host, "canvas.${var.domain}"] : [local.api_host]
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
resource "digitalocean_loadbalancer" "public" {
|
|
158
|
+
name = "${var.name}-lb"
|
|
159
|
+
region = var.region
|
|
160
|
+
droplet_ids = [digitalocean_droplet.app.id]
|
|
161
|
+
redirect_http_to_https = local.has_domain # domainless serves HTTP itself, nothing to redirect to
|
|
162
|
+
http_idle_timeout_seconds = 600 # DO's maximum
|
|
163
|
+
|
|
164
|
+
# With a domain: HTTPS 443 with the managed cert. Without: plain HTTP 80 on
|
|
165
|
+
# the LB IP. Same port meanings for the developer either way (API on the root).
|
|
166
|
+
dynamic "forwarding_rule" {
|
|
167
|
+
for_each = local.has_domain ? [1] : []
|
|
168
|
+
content {
|
|
169
|
+
entry_port = 443
|
|
170
|
+
entry_protocol = "https"
|
|
171
|
+
target_port = 4105
|
|
172
|
+
target_protocol = "http"
|
|
173
|
+
certificate_name = one(digitalocean_certificate.api[*].name)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
dynamic "forwarding_rule" {
|
|
177
|
+
for_each = local.has_domain ? [] : [1]
|
|
178
|
+
content {
|
|
179
|
+
entry_port = 80
|
|
180
|
+
entry_protocol = "http"
|
|
181
|
+
target_port = 4105
|
|
182
|
+
target_protocol = "http"
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
# POC (canvas_public = true, DECIDED 2026-07-29): Canvas rides the SAME LB on a
|
|
187
|
+
# second port — https://canvas.<domain>:3001 (a SAN on the api certificate, an A record to this same LB). ONE load balancer total: DO LBs can't
|
|
188
|
+
# host-route, and a clean second hostname would cost a second LB; the POC takes
|
|
189
|
+
# the port in the URL instead. Domainless: same second port, plain HTTP.
|
|
190
|
+
dynamic "forwarding_rule" {
|
|
191
|
+
for_each = var.canvas_public ? [1] : []
|
|
192
|
+
content {
|
|
193
|
+
entry_port = 3001
|
|
194
|
+
entry_protocol = local.has_domain ? "https" : "http"
|
|
195
|
+
target_port = 3001
|
|
196
|
+
target_protocol = "http"
|
|
197
|
+
certificate_name = one(digitalocean_certificate.api[*].name)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
healthcheck {
|
|
203
|
+
port = 4105
|
|
204
|
+
protocol = "http"
|
|
205
|
+
path = "/health"
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
resource "digitalocean_record" "canvas" {
|
|
210
|
+
count = local.has_domain && var.canvas_public && var.manage_dns ? 1 : 0
|
|
211
|
+
domain = var.domain
|
|
212
|
+
type = "A"
|
|
213
|
+
name = "canvas"
|
|
214
|
+
value = digitalocean_loadbalancer.public.ip
|
|
215
|
+
ttl = 300
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
# Optional DNS (manage_dns = true and the domain hosted on DO).
|
|
219
|
+
resource "digitalocean_record" "api" {
|
|
220
|
+
count = local.has_domain && var.manage_dns ? 1 : 0
|
|
221
|
+
domain = var.domain
|
|
222
|
+
type = "A"
|
|
223
|
+
name = "api"
|
|
224
|
+
value = digitalocean_loadbalancer.public.ip
|
|
225
|
+
ttl = 300
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
# ── Postgres: Managed PG fronted by its built-in PgBouncer (the law) ──────────
|
|
229
|
+
resource "digitalocean_database_cluster" "pg" {
|
|
230
|
+
count = local.provision_pg ? 1 : 0
|
|
231
|
+
name = "${var.name}-pg"
|
|
232
|
+
engine = "pg"
|
|
233
|
+
version = "16"
|
|
234
|
+
size = local.s.pg
|
|
235
|
+
region = var.region
|
|
236
|
+
node_count = 1
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
resource "digitalocean_database_db" "universe" {
|
|
240
|
+
count = local.pg_managed ? 1 : 0
|
|
241
|
+
cluster_id = local.pg_cluster_id
|
|
242
|
+
name = "universe"
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
resource "digitalocean_database_user" "universe" {
|
|
246
|
+
count = local.pg_managed ? 1 : 0
|
|
247
|
+
cluster_id = local.pg_cluster_id
|
|
248
|
+
name = "universe"
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
# Transaction-mode pool: ~hundreds of client connections over `pgbouncer` backend
|
|
252
|
+
# slots. Services use the POOLED url; migrations use the DIRECT url
|
|
253
|
+
# (DATABASE_URL_DIRECT — db-setup prefers it; DDL doesn't ride transaction mode).
|
|
254
|
+
resource "digitalocean_database_connection_pool" "universe" {
|
|
255
|
+
count = local.pg_managed ? 1 : 0
|
|
256
|
+
cluster_id = local.pg_cluster_id
|
|
257
|
+
name = "universe-pool"
|
|
258
|
+
mode = "transaction"
|
|
259
|
+
size = local.s.pgbouncer
|
|
260
|
+
db_name = digitalocean_database_db.universe[0].name
|
|
261
|
+
user = digitalocean_database_user.universe[0].name
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
resource "digitalocean_database_firewall" "pg" {
|
|
265
|
+
count = local.pg_managed ? 1 : 0
|
|
266
|
+
cluster_id = local.pg_cluster_id
|
|
267
|
+
rule {
|
|
268
|
+
type = "droplet"
|
|
269
|
+
value = digitalocean_droplet.app.id
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
# ── Redis: ALWAYS ours (Managed Redis, TLS, droplet-only). Never BYO — it is
|
|
274
|
+
# the shared-state backbone and the active/active future; the stack owns it. ──
|
|
275
|
+
resource "digitalocean_database_cluster" "redis" {
|
|
276
|
+
name = "${var.name}-redis"
|
|
277
|
+
engine = "redis"
|
|
278
|
+
version = "7"
|
|
279
|
+
size = local.s.redis
|
|
280
|
+
region = var.region
|
|
281
|
+
node_count = 1
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
resource "digitalocean_database_firewall" "redis" {
|
|
285
|
+
cluster_id = digitalocean_database_cluster.redis.id
|
|
286
|
+
rule {
|
|
287
|
+
type = "droplet"
|
|
288
|
+
value = digitalocean_droplet.app.id
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
# ── Per-deployment credential-encryption key (SECURITY.md § Credential
|
|
293
|
+
# encryption at rest): no deployment ever runs on the committed default. ──────
|
|
294
|
+
resource "random_password" "credential_key" {
|
|
295
|
+
length = 44
|
|
296
|
+
special = false
|
|
297
|
+
}
|