unoverse 0.1.86 → 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 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
- async function probe(url) {
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 }}
@@ -42,6 +42,13 @@ 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
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)"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unoverse",
3
- "version": "0.1.86",
3
+ "version": "0.1.87",
4
4
  "description": "The Unoverse front door — create a Studio project, a universe, or a client app, and launch Studio.",
5
5
  "license": "SEE LICENSE IN README.md",
6
6
  "type": "module",