unoverse 0.1.84 → 0.1.86
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/operator/ansible/playbooks/db-setup.yml +25 -1
- package/operator/ansible/playbooks/test-connectivity.yml +22 -22
- 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 +1 -1
- package/operator/infra/digitalocean/prices.tf +3 -0
- package/package.json +1 -1
|
@@ -127,9 +127,33 @@
|
|
|
127
127
|
register: migrate_result
|
|
128
128
|
ignore_errors: yes
|
|
129
129
|
|
|
130
|
+
# THE WHOLE FILE IS NOT THE ANSWER TO "DID IT WORK". This printed every line of the
|
|
131
|
+
# baseline — several hundred lines of CREATE TABLE nobody asked to read, burying the one
|
|
132
|
+
# line that says whether anything ran. Keep the names and the verdict.
|
|
130
133
|
- name: "[4/5] Migration output"
|
|
131
134
|
debug:
|
|
132
|
-
msg:
|
|
135
|
+
msg: >-
|
|
136
|
+
{{ (migrate_result.stdout_lines | default([]) | select('match', '^> - ') | list)
|
|
137
|
+
+ (migrate_result.stdout_lines | default([]) | select('search', 'Migrations complete|No migrations') | list)
|
|
138
|
+
| default(['No output'], true) }}
|
|
139
|
+
|
|
140
|
+
# SERVICES BOOTED BEFORE THIS SCHEMA EXISTED. install.yml has to start the containers
|
|
141
|
+
# first — migrations run THROUGH the unoverse container, so there is nothing to run them
|
|
142
|
+
# in until it is up. The cost is that every service that touches the database at boot
|
|
143
|
+
# does so against an empty one: memory logged "permission denied for schema public" and
|
|
144
|
+
# then 'relation "memories" does not exist', and stayed in that state until something
|
|
145
|
+
# restarted it. Restarting here is the missing half of that ordering, and it only ever
|
|
146
|
+
# happens on a first setup.
|
|
147
|
+
- name: "[4b/5] Restart services against the migrated schema"
|
|
148
|
+
command: docker compose restart
|
|
149
|
+
args:
|
|
150
|
+
chdir: /opt/gravity
|
|
151
|
+
when: migrate_result.rc | default(1) == 0
|
|
152
|
+
|
|
153
|
+
- name: "[4b/5] Settle"
|
|
154
|
+
pause:
|
|
155
|
+
seconds: 15
|
|
156
|
+
when: migrate_result.rc | default(1) == 0
|
|
133
157
|
|
|
134
158
|
- name: "[5/5] Verify database connectivity"
|
|
135
159
|
uri:
|
|
@@ -134,36 +134,38 @@
|
|
|
134
134
|
register: api_write_check
|
|
135
135
|
ignore_errors: yes
|
|
136
136
|
|
|
137
|
-
|
|
137
|
+
# AN EMPTY CATALOGUE IS NOT A BROKEN ONE. Nodes arrive as rows — installed from the
|
|
138
|
+
# marketplace or published from Studio — so a universe on its first day has none, and
|
|
139
|
+
# that is the correct state, not a fault. This used to fail outright on zero and advise
|
|
140
|
+
# "run deploy.sh rebuild", a command that does not exist, which turned every first
|
|
141
|
+
# deploy into a red line pointing at nothing.
|
|
142
|
+
#
|
|
143
|
+
# What IS worth failing on is the runtime not answering at all: that means the catalogue
|
|
144
|
+
# could not be read, which is a different fact from it being empty.
|
|
145
|
+
- name: "[10/13] Check the node catalogue is readable"
|
|
138
146
|
shell: |
|
|
139
147
|
cd /opt/gravity
|
|
140
148
|
# The internal :4106 runtime is Docker-network-only (never published), and the
|
|
141
149
|
# public :4105 routes are JWT-gated — so read the catalog from INSIDE the
|
|
142
150
|
# container. node:20-slim has no curl; use node fetch.
|
|
143
151
|
NODE_COUNT=$(docker compose exec -T unoverse node -e \
|
|
144
|
-
"fetch('http://127.0.0.1:4106/nodes').then(r=>r.json()).then(d=>console.log((d.nodes||[]).length)).catch(()=>console.log(
|
|
152
|
+
"fetch('http://127.0.0.1:4106/nodes').then(r=>r.json()).then(d=>console.log((d.nodes||[]).length)).catch(()=>console.log('unreachable'))" 2>/dev/null | tr -d ' \r')
|
|
153
|
+
if [ "$NODE_COUNT" = "unreachable" ]; then
|
|
154
|
+
echo "nodes=unreachable"
|
|
155
|
+
echo "The runtime did not answer on :4106 — the catalogue could not be read"
|
|
156
|
+
exit 1
|
|
157
|
+
fi
|
|
145
158
|
echo "nodes=${NODE_COUNT}"
|
|
146
159
|
if [ "${NODE_COUNT:-0}" -eq 0 ] 2>/dev/null; then
|
|
147
|
-
echo "
|
|
148
|
-
exit 1
|
|
160
|
+
echo "(none installed yet — install from the marketplace or publish from Studio)"
|
|
149
161
|
fi
|
|
150
162
|
register: plugin_check
|
|
151
163
|
ignore_errors: yes
|
|
152
164
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
PKG_COUNT=$(docker compose exec -T unoverse ls /app/host_packages 2>/dev/null | wc -l | tr -d ' ')
|
|
158
|
-
echo "packages_mounted=${PKG_COUNT}"
|
|
159
|
-
[ "$PKG_COUNT" -gt 0 ]
|
|
160
|
-
else
|
|
161
|
-
echo "packages_mounted=0"
|
|
162
|
-
echo "WARNING: No packages directory — run deploy.sh or unoverse update"
|
|
163
|
-
exit 1
|
|
164
|
-
fi
|
|
165
|
-
register: packages_check
|
|
166
|
-
ignore_errors: yes
|
|
165
|
+
# NO PACKAGES CHECK. It looked for /app/host_packages, a bind mount that was retired
|
|
166
|
+
# when nodes moved to npm at runtime plus the marketplace. Nothing declares
|
|
167
|
+
# PACKAGES_PATH any more and docker-compose.yml has no such volume, so the check could
|
|
168
|
+
# only ever report 0 and tell the operator to run a command that would not fix it.
|
|
167
169
|
|
|
168
170
|
- name: "[12/14] Check for errors in service logs"
|
|
169
171
|
shell: |
|
|
@@ -245,11 +247,9 @@
|
|
|
245
247
|
── Prompt Blocks (internal :4106) ──
|
|
246
248
|
{{ prompt_blocks_check.stdout_lines[0] | default('(skipped)') }}
|
|
247
249
|
|
|
248
|
-
──
|
|
249
|
-
|
|
250
|
+
── Node catalogue ──
|
|
251
|
+
{{ plugin_check.stdout_lines[0] | default('(skipped)') }}
|
|
250
252
|
{{ plugin_check.stdout_lines[1] | default('') }}
|
|
251
|
-
Packages: {{ packages_check.stdout_lines[0] | default('(skipped)') }}
|
|
252
|
-
{{ packages_check.stdout_lines[1] | default('') }}
|
|
253
253
|
|
|
254
254
|
── Recent Errors in Logs ──
|
|
255
255
|
{{ log_errors.stdout | default('(skipped)') }}
|
|
@@ -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
|
|
|
@@ -44,7 +44,7 @@ output "lb_ip" {
|
|
|
44
44
|
|
|
45
45
|
output "canvas_url" {
|
|
46
46
|
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}
|
|
47
|
+
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
48
|
}
|
|
49
49
|
|
|
50
50
|
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