unoverse 0.1.85 → 0.1.87
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/lib/urls.mjs +30 -5
- package/operator/ansible/playbooks/deploy-images.yml +19 -0
- package/operator/infra/aws/main.tf +3 -3
- package/operator/infra/aws/outputs.tf +2 -2
- package/operator/infra/digitalocean/main.tf +57 -21
- package/operator/infra/digitalocean/outputs.tf +8 -1
- package/operator/infra/digitalocean/prices.tf +3 -0
- package/package.json +1 -1
package/lib/urls.mjs
CHANGED
|
@@ -39,6 +39,7 @@ function section(title, rows, subtitle) {
|
|
|
39
39
|
import { existsSync } from "node:fs";
|
|
40
40
|
import { join, resolve, dirname } from "node:path";
|
|
41
41
|
import { spawnSync } from "node:child_process";
|
|
42
|
+
import { lookup } from "node:dns/promises";
|
|
42
43
|
|
|
43
44
|
import { bold, dim, cyan, green, red } from "./ui.mjs";
|
|
44
45
|
|
|
@@ -75,11 +76,35 @@ function terraformOutputs(root) {
|
|
|
75
76
|
return {};
|
|
76
77
|
}
|
|
77
78
|
|
|
78
|
-
|
|
79
|
+
/**
|
|
80
|
+
* "no answer" AND "answering from the wrong machine" ARE DIFFERENT PROBLEMS.
|
|
81
|
+
*
|
|
82
|
+
* A URL that does not respond used to report identically whether the service was down or
|
|
83
|
+
* the hostname resolved somewhere else entirely. That sent an operator to look at
|
|
84
|
+
* infrastructure that was already working: two load balancers up, a verified certificate,
|
|
85
|
+
* and every deployed URL reading "no answer" because a resolver was still holding a
|
|
86
|
+
* deleted A record. The address had a cached answer pointing at a destroyed server, which
|
|
87
|
+
* is not a fact any amount of staring at containers reveals.
|
|
88
|
+
*
|
|
89
|
+
* So when a probe fails, ask what the name actually resolves to and compare it with the
|
|
90
|
+
* address the ground says it should be. Stale DNS then says so in the one place the
|
|
91
|
+
* operator is already looking.
|
|
92
|
+
*/
|
|
93
|
+
async function probe(url, expectedIp) {
|
|
79
94
|
try {
|
|
80
95
|
const res = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(5000) });
|
|
81
96
|
return { up: true, status: res.status };
|
|
82
97
|
} catch {
|
|
98
|
+
if (!expectedIp) return { up: false };
|
|
99
|
+
try {
|
|
100
|
+
const { hostname } = new URL(url);
|
|
101
|
+
const { address } = await lookup(hostname);
|
|
102
|
+
if (address !== expectedIp) {
|
|
103
|
+
return { up: false, note: `DNS says ${address}, should be ${expectedIp} — a resolver is holding an old record` };
|
|
104
|
+
}
|
|
105
|
+
} catch {
|
|
106
|
+
return { up: false, note: "that hostname does not resolve yet" };
|
|
107
|
+
}
|
|
83
108
|
return { up: false };
|
|
84
109
|
}
|
|
85
110
|
}
|
|
@@ -103,14 +128,14 @@ export async function urls() {
|
|
|
103
128
|
domain = /^https:\/\/api\.(.+)$/.exec(apiBase)?.[1] || "";
|
|
104
129
|
const candidates = [];
|
|
105
130
|
if (apiBase) {
|
|
106
|
-
candidates.push({ label: "API", url: apiBase, note: "MCP at /mcp" });
|
|
107
|
-
candidates.push({ label: "Health", url: `${apiBase}/health
|
|
131
|
+
candidates.push({ label: "API", url: apiBase, note: "MCP at /mcp", expect: tf.lb_ip });
|
|
132
|
+
candidates.push({ label: "Health", url: `${apiBase}/health`, expect: tf.lb_ip });
|
|
108
133
|
}
|
|
109
134
|
const canvas = tf.canvas_url && tf.canvas_url.startsWith("http") ? tf.canvas_url : null;
|
|
110
|
-
if (canvas) candidates.push({ label: "Canvas", url: canvas, note: "add to IdP origins" });
|
|
135
|
+
if (canvas) candidates.push({ label: "Canvas", url: canvas, note: "add to IdP origins", expect: tf.canvas_lb_ip });
|
|
111
136
|
if (tf.deploy_host) candidates.push({ label: "Logs", url: `http://${tf.deploy_host}:8080`, note: "admin IP only" });
|
|
112
137
|
if (tf.cognito_hosted_ui) candidates.push({ label: "Login", url: tf.cognito_hosted_ui });
|
|
113
|
-
deployed = await Promise.all(candidates.map(async (c) => ({ ...c, ...(await probe(c.url)) })));
|
|
138
|
+
deployed = await Promise.all(candidates.map(async (c) => ({ ...c, ...(await probe(c.url, c.expect)) })));
|
|
114
139
|
}
|
|
115
140
|
|
|
116
141
|
const anyUp = [...local, ...deployed].some((r) => r.up);
|
|
@@ -42,6 +42,25 @@
|
|
|
42
42
|
src: "{{ universe_root | default(playbook_dir + '/../..') }}/docker-compose.yml"
|
|
43
43
|
dest: "{{ gravity_dir }}/docker-compose.yml"
|
|
44
44
|
|
|
45
|
+
# THE ENVIRONMENT TRAVELS WITH THE DEPLOY TOO. This step used to be skipped on the
|
|
46
|
+
# grounds that `.env` held per-server values a deploy must not clobber. That stopped
|
|
47
|
+
# being true when the ground started rendering it in full: it is `terraform output -raw
|
|
48
|
+
# env_production`, derived from the same state that built the infrastructure, and
|
|
49
|
+
# nothing on the server edits it.
|
|
50
|
+
#
|
|
51
|
+
# Skipping it meant a change to the ground reached docker-compose.yml and stopped there.
|
|
52
|
+
# Setting a domain updated the compose file, while the environment still said
|
|
53
|
+
# DOMAIN= and API_URL=http://<lb-ip> — so Canvas loaded over HTTPS and called the API
|
|
54
|
+
# over plain HTTP at a raw IP, and the browser blocked it as mixed content. The compose
|
|
55
|
+
# file and the environment describe one deployment; shipping one without the other
|
|
56
|
+
# leaves them disagreeing.
|
|
57
|
+
- name: "[1b/4] Sync .env (the ground renders it, so it travels with the deploy)"
|
|
58
|
+
copy:
|
|
59
|
+
src: "{{ env_file }}"
|
|
60
|
+
dest: /opt/gravity/.env
|
|
61
|
+
owner: "{{ ansible_user }}"
|
|
62
|
+
mode: "0600"
|
|
63
|
+
|
|
45
64
|
- name: "[2/4] Pull latest platform images"
|
|
46
65
|
shell: |
|
|
47
66
|
cd {{ gravity_dir }}
|
|
@@ -493,7 +493,7 @@ resource "aws_iam_access_key" "bedrock" {
|
|
|
493
493
|
resource "aws_acm_certificate" "public" {
|
|
494
494
|
count = local.has_domain ? 1 : 0
|
|
495
495
|
domain_name = local.api_host
|
|
496
|
-
subject_alternative_names = var.canvas_public ? ["
|
|
496
|
+
subject_alternative_names = var.canvas_public ? ["canvas.${var.domain}"] : []
|
|
497
497
|
validation_method = "DNS"
|
|
498
498
|
|
|
499
499
|
lifecycle {
|
|
@@ -625,7 +625,7 @@ resource "aws_lb_listener_rule" "canvas" {
|
|
|
625
625
|
}
|
|
626
626
|
condition {
|
|
627
627
|
host_header {
|
|
628
|
-
values = ["
|
|
628
|
+
values = ["canvas.${var.domain}"]
|
|
629
629
|
}
|
|
630
630
|
}
|
|
631
631
|
}
|
|
@@ -661,7 +661,7 @@ resource "aws_route53_record" "api" {
|
|
|
661
661
|
resource "aws_route53_record" "unoverse" {
|
|
662
662
|
count = local.dns_auto && var.canvas_public ? 1 : 0
|
|
663
663
|
zone_id = var.route53_zone_id
|
|
664
|
-
name = "
|
|
664
|
+
name = "canvas.${var.domain}"
|
|
665
665
|
type = "A"
|
|
666
666
|
|
|
667
667
|
alias {
|
|
@@ -9,7 +9,7 @@ output "deploy_host" {
|
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
output "alb_dns_name" {
|
|
12
|
-
description = "Point api.<domain> (and
|
|
12
|
+
description = "Point api.<domain> (and canvas.<domain> when canvas_public) at this — CNAME/ALIAS. Created automatically when route53_zone_id is set."
|
|
13
13
|
value = aws_lb.public.dns_name
|
|
14
14
|
}
|
|
15
15
|
|
|
@@ -24,7 +24,7 @@ output "acm_validation_records" {
|
|
|
24
24
|
|
|
25
25
|
output "canvas_url" {
|
|
26
26
|
description = "Public Canvas URL (canvas_public = true only) — add it to the client origins."
|
|
27
|
-
value = var.canvas_public ? (local.has_domain ? "https://
|
|
27
|
+
value = var.canvas_public ? (local.has_domain ? "https://canvas.${var.domain}" : "http://${aws_lb.public.dns_name}:3001") : "canvas is admin-only (direct http://<instance-ip>:3001 from admin_cidr)"
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
output "api_url" {
|
|
@@ -116,17 +116,18 @@ resource "digitalocean_firewall" "app" {
|
|
|
116
116
|
port_range = "4105"
|
|
117
117
|
source_load_balancer_uids = [digitalocean_loadbalancer.public.id]
|
|
118
118
|
}
|
|
119
|
-
# Canvas (operator UI)
|
|
120
|
-
#
|
|
121
|
-
#
|
|
122
|
-
#
|
|
123
|
-
#
|
|
124
|
-
# Canvas at https://api.<domain>:3001. Direct-IP access stays admin-only either way.
|
|
119
|
+
# Canvas (operator UI). Direct access to :3001 on the droplet stays admin-only, same
|
|
120
|
+
# trust ring as SSH and Dozzle, whatever else is true.
|
|
121
|
+
# With canvas_public: whichever load balancer fronts it may reach :3001 too — the canvas
|
|
122
|
+
# LB when there is a domain, the main one when there is not. Naming both here would open
|
|
123
|
+
# 3001 to an LB that is not serving Canvas in that mode.
|
|
125
124
|
inbound_rule {
|
|
126
|
-
protocol
|
|
127
|
-
port_range
|
|
128
|
-
source_addresses
|
|
129
|
-
source_load_balancer_uids = var.canvas_public ?
|
|
125
|
+
protocol = "tcp"
|
|
126
|
+
port_range = "3001"
|
|
127
|
+
source_addresses = [var.admin_cidr]
|
|
128
|
+
source_load_balancer_uids = var.canvas_public ? (
|
|
129
|
+
local.has_domain ? [digitalocean_loadbalancer.canvas[0].id] : [digitalocean_loadbalancer.public.id]
|
|
130
|
+
) : []
|
|
130
131
|
}
|
|
131
132
|
|
|
132
133
|
outbound_rule {
|
|
@@ -183,18 +184,16 @@ resource "digitalocean_loadbalancer" "public" {
|
|
|
183
184
|
}
|
|
184
185
|
}
|
|
185
186
|
|
|
186
|
-
#
|
|
187
|
-
# second port
|
|
188
|
-
#
|
|
189
|
-
# the port in the URL instead. Domainless: same second port, plain HTTP.
|
|
187
|
+
# DOMAINLESS ONLY. Without a hostname there is nothing to route by, so Canvas takes a
|
|
188
|
+
# second port on this LB's IP. With a domain it gets its own load balancer below and a
|
|
189
|
+
# clean https://canvas.<domain> — see the note there.
|
|
190
190
|
dynamic "forwarding_rule" {
|
|
191
|
-
for_each = var.canvas_public ? [1] : []
|
|
191
|
+
for_each = var.canvas_public && !local.has_domain ? [1] : []
|
|
192
192
|
content {
|
|
193
|
-
entry_port
|
|
194
|
-
entry_protocol
|
|
195
|
-
target_port
|
|
196
|
-
target_protocol
|
|
197
|
-
certificate_name = one(digitalocean_certificate.api[*].name)
|
|
193
|
+
entry_port = 3001
|
|
194
|
+
entry_protocol = "http"
|
|
195
|
+
target_port = 3001
|
|
196
|
+
target_protocol = "http"
|
|
198
197
|
}
|
|
199
198
|
}
|
|
200
199
|
|
|
@@ -206,12 +205,49 @@ resource "digitalocean_loadbalancer" "public" {
|
|
|
206
205
|
}
|
|
207
206
|
}
|
|
208
207
|
|
|
208
|
+
# CANVAS GETS ITS OWN LOAD BALANCER, so its URL carries no port.
|
|
209
|
+
#
|
|
210
|
+
# Supersedes the 2026-07-29 decision to run one LB and put :3001 in the URL. A port in a
|
|
211
|
+
# hostname is not a URL anyone ships: it breaks the expectation that https means 443, it
|
|
212
|
+
# has to be explained to every operator, and it leaks an implementation detail of the
|
|
213
|
+
# ingress into the address bar. The reason for it was real — DigitalOcean load balancers
|
|
214
|
+
# route on PORT only, never on the Host header, so one LB genuinely cannot serve two
|
|
215
|
+
# hostnames — but the conclusion was wrong. The right answer to "the product cannot do
|
|
216
|
+
# this" is a second load balancer, not a worse address.
|
|
217
|
+
#
|
|
218
|
+
# ~$12/month, and only when a domain is set AND Canvas is public. Domainless universes
|
|
219
|
+
# keep the second port on the main LB above: with no hostname there is nothing to route by,
|
|
220
|
+
# so a second LB would buy nothing.
|
|
221
|
+
resource "digitalocean_loadbalancer" "canvas" {
|
|
222
|
+
count = local.has_domain && var.canvas_public ? 1 : 0
|
|
223
|
+
name = "${var.name}-canvas-lb"
|
|
224
|
+
region = var.region
|
|
225
|
+
droplet_ids = [digitalocean_droplet.app.id]
|
|
226
|
+
redirect_http_to_https = true
|
|
227
|
+
http_idle_timeout_seconds = 600
|
|
228
|
+
|
|
229
|
+
forwarding_rule {
|
|
230
|
+
entry_port = 443
|
|
231
|
+
entry_protocol = "https"
|
|
232
|
+
target_port = 3001
|
|
233
|
+
target_protocol = "http"
|
|
234
|
+
certificate_name = one(digitalocean_certificate.api[*].name)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
# Canvas has no /health of its own; the root answering is the liveness signal.
|
|
238
|
+
healthcheck {
|
|
239
|
+
port = 3001
|
|
240
|
+
protocol = "http"
|
|
241
|
+
path = "/"
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
209
245
|
resource "digitalocean_record" "canvas" {
|
|
210
246
|
count = local.has_domain && var.canvas_public && var.manage_dns ? 1 : 0
|
|
211
247
|
domain = var.domain
|
|
212
248
|
type = "A"
|
|
213
249
|
name = "canvas"
|
|
214
|
-
value = digitalocean_loadbalancer.
|
|
250
|
+
value = digitalocean_loadbalancer.canvas[0].ip
|
|
215
251
|
ttl = 300
|
|
216
252
|
}
|
|
217
253
|
|
|
@@ -42,9 +42,16 @@ output "lb_ip" {
|
|
|
42
42
|
value = digitalocean_loadbalancer.public.ip
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
# Canvas's own load balancer address. Read by `unoverse where` so a hostname resolving to
|
|
46
|
+
# something else can be reported as stale DNS rather than as a dead service.
|
|
47
|
+
output "canvas_lb_ip" {
|
|
48
|
+
description = "The Canvas load balancer's IP — canvas.<domain>'s A record target. Empty when Canvas is not public or there is no domain."
|
|
49
|
+
value = length(digitalocean_loadbalancer.canvas) > 0 ? digitalocean_loadbalancer.canvas[0].ip : ""
|
|
50
|
+
}
|
|
51
|
+
|
|
45
52
|
output "canvas_url" {
|
|
46
53
|
description = "Public Canvas URL (canvas_public = true only) — add it to the IdP's allowed origins."
|
|
47
|
-
value = var.canvas_public ? (local.has_domain ? "https://canvas.${var.domain}
|
|
54
|
+
value = var.canvas_public ? (local.has_domain ? "https://canvas.${var.domain}" : "http://${digitalocean_loadbalancer.public.ip}:3001") : "canvas is admin-only (direct http://<droplet-ip>:3001 from admin_cidr)"
|
|
48
55
|
}
|
|
49
56
|
|
|
50
57
|
output "api_url" {
|
|
@@ -35,6 +35,9 @@ locals {
|
|
|
35
35
|
local.provision_pg ? lookup(local.prices, local.s.pg, 0) : 0,
|
|
36
36
|
lookup(local.prices, local.s.redis, 0),
|
|
37
37
|
local.prices["lb"],
|
|
38
|
+
# Canvas's own load balancer, so its URL needs no port. Only with a domain: without
|
|
39
|
+
# one it shares the main LB's IP on a second port and costs nothing extra.
|
|
40
|
+
local.has_domain && var.canvas_public ? local.prices["lb"] : 0,
|
|
38
41
|
])
|
|
39
42
|
}
|
|
40
43
|
|
package/package.json
CHANGED