unoverse 0.1.76 → 0.1.78

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.
@@ -14,8 +14,41 @@ terraform {
14
14
  }
15
15
  }
16
16
 
17
+ # ── One universe, one group ───────────────────────────────────────────────────
18
+ #
19
+ # AWS has no projects. Its equivalent is TAGS, and `default_tags` is the only honest way
20
+ # to apply them: it stamps every resource this configuration creates, so nothing is
21
+ # forgotten the day someone adds a resource and does not think about tagging. The console
22
+ # view comes from the Resource Group below, and the same tag drives cost allocation, which
23
+ # a DigitalOcean project cannot do.
24
+ #
25
+ # It touches ONLY what this stack creates. An adopted database keeps its own tags, in
26
+ # keeping with the rule that a universe never takes ownership of what it borrowed
27
+ # (INFRASTRUCTURE.md, "Borrowed resources are never owned").
17
28
  provider "aws" {
18
29
  region = var.region
30
+
31
+ default_tags {
32
+ tags = {
33
+ Universe = var.name
34
+ ManagedBy = "unoverse"
35
+ Size = var.size
36
+ }
37
+ }
38
+ }
39
+
40
+ # The console's answer to "what does this universe own". A tag query rather than a
41
+ # membership list, so it stays correct as the ground grows without anyone maintaining it.
42
+ resource "aws_resourcegroups_group" "universe" {
43
+ name = var.name
44
+ description = "unoverse universe: ${var.name}"
45
+
46
+ resource_query {
47
+ query = jsonencode({
48
+ ResourceTypeFilters = ["AWS::AllSupported"]
49
+ TagFilters = [{ Key = "Universe", Values = [var.name] }]
50
+ })
51
+ }
19
52
  }
20
53
 
21
54
  # ── Network: default VPC + two security groups ─────────────────────────────────
@@ -1,13 +1,14 @@
1
+ # NO DEFAULTS ON EITHER OF THESE — see the same note in infra/digitalocean/variables.tf.
2
+ # Where it runs and what it is called are the developer's answers, and a default quietly
3
+ # supplies mine instead.
1
4
  variable "region" {
2
5
  description = "AWS region. Must have Bedrock model access enabled for the models you use."
3
6
  type = string
4
- default = "us-east-1"
5
7
  }
6
8
 
7
9
  variable "name" {
8
- description = "Prefix for every resource (also the Cognito domain prefix, so keep it dns-safe)."
10
+ description = "Prefix for every resource (also the Cognito domain prefix, so keep it dns-safe), and the resource group they are tagged into."
9
11
  type = string
10
- default = "universe-poc"
11
12
  }
12
13
 
13
14
  variable "admin_cidr" {
@@ -5,16 +5,18 @@ variable "do_token" {
5
5
  sensitive = true
6
6
  }
7
7
 
8
+ # NO DEFAULTS ON EITHER OF THESE. They used to default to "lon1" and "universe-poc", which
9
+ # meant a tfvars missing them built somebody's universe in London under a placeholder name
10
+ # and reported success. Where it runs and what it is called are the developer's answers, so
11
+ # terraform should refuse to plan without them rather than quietly supply mine.
8
12
  variable "region" {
9
- description = "DigitalOcean region for everything."
13
+ description = "DigitalOcean region for everything (doctl compute region list)."
10
14
  type = string
11
- default = "lon1"
12
15
  }
13
16
 
14
17
  variable "name" {
15
- description = "Prefix for every resource."
18
+ description = "Prefix for every resource, and the name of the DigitalOcean project they are grouped under."
16
19
  type = string
17
- default = "universe-poc"
18
20
  }
19
21
 
20
22
  variable "size" {
@@ -20,16 +20,23 @@
20
20
  # trusted-sources list, so pointing it at a borrowed cluster deletes the operator's own
21
21
  # IP, their other droplets and their apps. Read the rules, add ours if missing, write
22
22
  # them back. Nothing else moves, and running it twice changes nothing.
23
- _grant_droplet_to_adopted_db() {
23
+ # Add or remove THIS universe's droplet in an adopted cluster's trusted sources.
24
+ # _adopted_db_access <cloud> grant|revoke [droplet_id]
25
+ # Symmetric on purpose. The grant existed and nothing revoked it, so a teardown left a
26
+ # stale rule behind — and worse, terraform's own destroy of an authoritative firewall
27
+ # resource PUT an EMPTY list and locked the operator out of a database it had borrowed.
28
+ # Both directions now touch exactly one rule and never the list.
29
+ _adopted_db_access() {
24
30
  local cloud="$1" dir="$ROOT/infra/$cloud" cluster droplet_id
31
+ local mode="$2"
25
32
  cluster=$(grep -E '^existing_pg_cluster_name[[:space:]]*=' "$dir/terraform.tfvars" 2>/dev/null | sed -E 's/.*"([^"]+)".*/\1/')
26
33
  [ -n "$cluster" ] || return 0
27
34
  [ -n "${DIGITALOCEAN_TOKEN:-}" ] || return 0
28
- droplet_id=$(terraform -chdir="$dir" state show digitalocean_droplet.app 2>/dev/null | awk -F'"' '/^ *id *=/{print $2; exit}')
35
+ droplet_id="${3:-$(terraform -chdir="$dir" state show digitalocean_droplet.app 2>/dev/null | awk -F'"' '/^ *id *=/{print $2; exit}')}"
29
36
  [ -n "$droplet_id" ] || return 0
30
37
 
31
- node - "$cluster" "$droplet_id" <<'NODE'
32
- const [cluster, droplet] = process.argv.slice(2);
38
+ node - "$cluster" "$droplet_id" "$mode" <<'NODE'
39
+ const [cluster, droplet, mode] = process.argv.slice(2);
33
40
  const T = process.env.DIGITALOCEAN_TOKEN;
34
41
  const H = { Authorization: `Bearer ${T}`, "Content-Type": "application/json" };
35
42
  const api = (p, o = {}) => fetch(`https://api.digitalocean.com/v2${p}`, { headers: H, ...o });
@@ -38,13 +45,21 @@ const api = (p, o = {}) => fetch(`https://api.digitalocean.com/v2${p}`, { header
38
45
  const db = (list.databases || []).find((d) => d.name === cluster);
39
46
  if (!db) return;
40
47
  const fw = await (await api(`/databases/${db.id}/firewall`)).json();
41
- const rules = (fw.rules || []).map((r) => ({ type: r.type, value: r.value }));
42
- if (rules.some((r) => r.type === "droplet" && String(r.value) === String(droplet))) return;
43
- rules.push({ type: "droplet", value: String(droplet) });
48
+ let rules = (fw.rules || []).map((r) => ({ type: r.type, value: r.value }));
49
+ const mine = (r) => r.type === "droplet" && String(r.value) === String(droplet);
50
+ const has = rules.some(mine);
51
+ if (mode === "revoke") {
52
+ if (!has) return;
53
+ rules = rules.filter((r) => !mine(r)); // ours only; every other rule survives
54
+ } else {
55
+ if (has) return;
56
+ rules.push({ type: "droplet", value: String(droplet) });
57
+ }
44
58
  const res = await api(`/databases/${db.id}/firewall`, { method: "PUT", body: JSON.stringify({ rules }) });
59
+ const what = mode === "revoke" ? "no longer reaches" : "may reach";
45
60
  console.log(res.ok
46
- ? ` \x1b[32m✓\x1b[0m This universe may reach ${cluster} \x1b[2m(added to its trusted sources, nothing else changed)\x1b[0m`
47
- : ` \x1b[33m!\x1b[0m Could not update ${cluster}'s trusted sources — add droplet ${droplet} by hand`);
61
+ ? ` \x1b[32m✓\x1b[0m This universe ${what} ${cluster} \x1b[2m(one rule changed, ${rules.length} left in place)\x1b[0m`
62
+ : ` \x1b[33m!\x1b[0m Could not update ${cluster}'s trusted sources — adjust droplet ${droplet} by hand`);
48
63
  })().catch(() => {});
49
64
  NODE
50
65
  }
@@ -110,6 +125,24 @@ _ensure_ground_config() {
110
125
  fi
111
126
  }
112
127
  echo ""
128
+
129
+ # YOUR IP MOVED. admin_cidr is captured once, when the ground is generated, and it is
130
+ # the ONLY address the firewall lets near port 22. Home broadband, a phone hotspot or a
131
+ # different office and the address is stale — at which point terraform applies happily,
132
+ # every resource is correct, and the ship step dies on "connect to host ... port 22:
133
+ # Operation timed out" with nothing on screen connecting the two. Check it every deploy
134
+ # and just fix it: the firewall is ours, and a one-rule update is not a decision.
135
+ local cur_ip cur_cidr have_cidr
136
+ cur_ip=$(curl -s --max-time 5 https://api.ipify.org 2>/dev/null || curl -s --max-time 5 https://ifconfig.me 2>/dev/null)
137
+ have_cidr=$(grep -E '^admin_cidr[[:space:]]*=' "$tfv" 2>/dev/null | sed -E 's/.*"([^"]+)".*/\1/')
138
+ if [ -n "$cur_ip" ] && [ -n "$have_cidr" ] && [ "$have_cidr" != "FILL_ME" ]; then
139
+ cur_cidr="$cur_ip/32"
140
+ if [ "$have_cidr" != "$cur_cidr" ]; then
141
+ node -e 'const fs=require("fs");const[f,v]=process.argv.slice(1);let s=fs.readFileSync(f,"utf8");s=s.replace(/^admin_cidr(\s*)=\s*"[^"]*"/m,"admin_cidr$1= "+JSON.stringify(v));fs.writeFileSync(f,s)' "$tfv" "$cur_cidr"
142
+ ok "Your address changed ${DIM}($have_cidr → $cur_cidr). Admin access follows you${NC}"
143
+ fi
144
+ fi
145
+
113
146
  # POSTGRES: ASK, DO NOT ASSUME. The ground discovers an existing cluster and writes
114
147
  # it as a COMMENTED line, so the default silently provisions a SECOND database next
115
148
  # to one the account already pays for. A choice with a monthly bill attached is a
@@ -229,25 +262,34 @@ _ground_apply() {
229
262
  *) fail "Terraform could not plan the change:"; tail -20 "$tmp/plan.log" | sed 's/^/ /'; rm -rf "$tmp"; return 1 ;;
230
263
  esac
231
264
 
232
- if ! terraform -chdir="$dir" show -json "$planfile" 2>/dev/null | node "$GRAVITY_LIB/tfsummary.mjs"; then
265
+ # STOPPING IS FOR LOSING SOMETHING, NOT FOR BUILDING IT. Typing `unoverse deploy` is
266
+ # already the decision; a y/N gate straight afterwards asks the same question twice and
267
+ # makes the developer's own instruction feel like a risk. The summary above still shows
268
+ # exactly what appears and what it costs — it is a briefing, not a checkpoint. tfsummary
269
+ # exits 3 when the plan destroys or replaces something, and THAT is worth interrupting.
270
+ local summary_rc=0
271
+ terraform -chdir="$dir" show -json "$planfile" 2>/dev/null | node "$GRAVITY_LIB/tfsummary.mjs"
272
+ summary_rc=${PIPESTATUS[1]}
273
+ if [ "$summary_rc" = "2" ]; then
274
+ # The plan could not be read, so nothing above is trustworthy. Show the real thing and
275
+ # fall back to asking: silence plus an unreadable plan is not consent.
233
276
  terraform -chdir="$dir" show "$planfile"
277
+ summary_rc=3
234
278
  fi
235
279
 
236
- # `read -p` does NOT interpret escapes, so ${DIM} printed as literal \033[2m. Colour
237
- # the hint with echo -e first, then take a plain prompt.
238
- # EXPLICIT YES, and it says so. Everywhere else in this CLI Enter means "go"; here it
239
- # means "stop", because this is the one prompt that starts a monthly bill. Keeping the
240
- # safety is right — leaving it as a bare [y/N] among Enter-means-go prompts is not.
241
- local REPLY
242
- echo -e " ${DIM}d shows the full technical plan${NC}"
243
- read -r -p " Type y to build this, or Enter to stop: " REPLY
244
- if [[ "$REPLY" =~ ^[Dd]$ ]]; then
245
- terraform -chdir="$dir" show "$planfile"
246
- read -r -p " Type y to build this, or Enter to stop: " REPLY
247
- fi
248
- if [[ ! "$REPLY" =~ ^[Yy]$ ]]; then
249
- info "Nothing was created. Run ${BOLD}unoverse deploy${NC} when you are ready"
250
- rm -rf "$tmp"; return 1
280
+ local docpage
281
+ [ "$cloud" = "aws" ] && docpage="aws" || docpage="digitalocean"
282
+ echo -e " ${DIM}How it fits together: https://github.com/unoverse-platform/docs/blob/main/architecture/$docpage.md${NC}"
283
+ echo ""
284
+
285
+ if [ "$summary_rc" = "3" ]; then
286
+ local REPLY
287
+ echo -e " ${RED}This removes or rebuilds things that already exist.${NC}"
288
+ read -r -p " Type y to go ahead, or Enter to stop: " REPLY
289
+ if [[ ! "$REPLY" =~ ^[Yy]$ ]]; then
290
+ info "Nothing was changed. Run ${BOLD}unoverse deploy${NC} when you are ready"
291
+ rm -rf "$tmp"; return 1
292
+ fi
251
293
  fi
252
294
 
253
295
  echo ""
@@ -311,7 +353,7 @@ cmd_deploy() {
311
353
  exit 1
312
354
  fi
313
355
 
314
- _grant_droplet_to_adopted_db "$cloud"
356
+ _adopted_db_access "$cloud" grant
315
357
 
316
358
 
317
359
  # Read deploy target from .env.production
@@ -91,6 +91,14 @@ cmd_destroy() {
91
91
  return 1
92
92
  fi
93
93
 
94
+ # Take our rule out of the borrowed cluster BEFORE the state goes, while the droplet id
95
+ # is still readable. One rule, ours, never the list.
96
+ if [ -n "$kept" ]; then
97
+ local dropped
98
+ dropped=$(terraform -chdir="$dir" state show digitalocean_droplet.app 2>/dev/null | awk -F'"' '/^ *id *=/{print $2; exit}')
99
+ [ -n "$dropped" ] && _adopted_db_access "$cloud" revoke "$dropped"
100
+ fi
101
+
94
102
  echo ""
95
103
  info "Taking it down..."
96
104
  echo ""
@@ -13,6 +13,44 @@ _ground_my_ip() {
13
13
  curl -s --max-time 5 https://api.ipify.org 2>/dev/null || curl -s --max-time 5 https://ifconfig.me 2>/dev/null
14
14
  }
15
15
 
16
+ # THE UNIVERSE HAS A NAME, so ask for one. This file used to write name = "universe-poc"
17
+ # as a literal, so every universe on every account carried the same placeholder — it names
18
+ # the droplet, the cache, the load balancer and the cloud project, and the developer first
19
+ # meets it in their provider dashboard, after the bill has started. A generated file is not
20
+ # a question: nobody edits it before running deploy.
21
+ #
22
+ # Sets GROUND_NAME (and GROUND_REGION when a default is passed). Both are sanitised to what
23
+ # cloud resource names allow, so a typed "My POC" cannot fail an apply five minutes in.
24
+ _ground_identity() {
25
+ local valid="$1" suggest
26
+ suggest=$(basename "$ROOT" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9-]+/-/g; s/^-+//; s/-+$//')
27
+ suggest="${suggest:-universe}"
28
+ echo ""
29
+ read -r -p " Name this universe [$suggest]: " GROUND_NAME
30
+ GROUND_NAME=$(echo "${GROUND_NAME:-$suggest}" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9-]+/-/g; s/^-+//; s/-+$//')
31
+ GROUND_NAME="${GROUND_NAME:-$suggest}"
32
+
33
+ # NO DEFAULT REGION. A default is a choice made on the developer's behalf, and the
34
+ # previous one was mine: `lon1`, written straight into the file, which is how a universe
35
+ # meant for Singapore was built in London. There is no neutral region to fall back to, so
36
+ # this asks until it gets a real answer and checks it against the provider's own list —
37
+ # a typo here surfaces as a five-minute apply that fails on the last resource.
38
+ # "-" means the region is not this ground's to ask: AWS reads it from the developer's own
39
+ # CLI configuration, which is already their answer.
40
+ GROUND_REGION=""
41
+ [ "$valid" = "-" ] && return 0
42
+ while [ -z "$GROUND_REGION" ]; do
43
+ read -r -p " Which region? " GROUND_REGION
44
+ GROUND_REGION=$(echo "$GROUND_REGION" | tr -d '[:space:]')
45
+ # Validate only against a list we actually retrieved. An empty list means the lookup
46
+ # failed, not that every region is wrong.
47
+ if [ -n "$GROUND_REGION" ] && [ -n "$valid" ] && ! echo "$valid" | tr ' ' '\n' | grep -qx "$GROUND_REGION"; then
48
+ warn "$GROUND_REGION is not a region in your account. Pick one from the list above"
49
+ GROUND_REGION=""
50
+ fi
51
+ done
52
+ }
53
+
16
54
  _ground_do() {
17
55
  local dir="$ROOT/infra/digitalocean"
18
56
  local out="$dir/terraform.tfvars"
@@ -88,13 +126,21 @@ _ground_do() {
88
126
  info "no existing Postgres cluster. Terraform will provision one"
89
127
  fi
90
128
 
129
+ local regions
130
+ regions=$(doctl compute region list --format Slug --no-header 2>/dev/null | tr -d '\r' | xargs)
131
+ if [ -n "$regions" ]; then
132
+ echo ""
133
+ echo -e " ${DIM}Regions available to you: $regions${NC}"
134
+ fi
135
+ _ground_identity "$regions"
136
+
91
137
  cat > "$out" <<EOF
92
138
  # Generated by \`unoverse ground do\` on $(date +%Y-%m-%d). Discovered values are
93
139
  # prefilled; every FILL_ME needs YOUR value before \`terraform apply\`.
94
140
  # Token: set DIGITALOCEAN_TOKEN in the environment (preferred) or do_token here.
95
141
 
96
- region = "lon1" # doctl compute region list
97
- name = "universe-poc"
142
+ region = "$GROUND_REGION" # doctl compute region list
143
+ name = "$GROUND_NAME"
98
144
  size = "small" # small (POC) | medium | large
99
145
  admin_cidr = "${ip:-FILL_ME}${ip:+/32}" # YOUR IP — SSH + Dozzle only
100
146
  ssh_key_name = "${first_key:-FILL_ME}" # must already exist in the DO account
@@ -185,12 +231,15 @@ _ground_aws() {
185
231
  info "multiple Route53 zones. Pick one in the file (listed there)"
186
232
  fi
187
233
 
234
+ # Region already came from the AWS CLI's own configuration, so only the name is asked.
235
+ _ground_identity "-"
236
+
188
237
  cat > "$out" <<EOF
189
238
  # Generated by \`unoverse ground aws\` on $(date +%Y-%m-%d). Discovered values are
190
239
  # prefilled; every FILL_ME needs YOUR value before \`terraform apply\`.
191
240
 
192
241
  region = "$region"
193
- name = "universe-poc"
242
+ name = "$GROUND_NAME"
194
243
  admin_cidr = "${ip:-FILL_ME}${ip:+/32}" # YOUR IP — the only SSH source
195
244
  ssh_key_name = "${first_key:-FILL_ME}" # must already exist in the region
196
245
  admin_email = "FILL_ME" # initial admin (Cognito user, all roles; invite emailed)
@@ -11,6 +11,11 @@
11
11
  * their size, counts the plumbing (database users, firewall rules, generated passwords)
12
12
  * rather than listing it, and shouts about anything being destroyed.
13
13
  *
14
+ * EXIT CODE IS THE ANSWER TO "should the caller stop and ask?". 0 when the plan only adds
15
+ * or adjusts — deploy just builds, because typing `unoverse deploy` already said to. 3
16
+ * when something is destroyed or replaced, which is the only case worth interrupting a
17
+ * developer for. 2 when the JSON could not be read, so the caller shows raw terraform.
18
+ *
14
19
  * NOT A COST ORACLE. The developer sees an order of magnitude rather than a surprise. Rates
15
20
  * move, so the total is labelled approximate and the real bill is the provider's.
16
21
  *
@@ -138,7 +143,7 @@ process.stdin.on("end", () => {
138
143
  const out = [];
139
144
  if (total === 0) {
140
145
  console.log(`\n ${green("●")} Your infrastructure already matches. Nothing to change.\n`);
141
- process.exit(0);
146
+ return;
142
147
  }
143
148
 
144
149
  const width = Math.max(...Object.values(groups).flat().map((d) => d.what.length), 0) + 2;
@@ -169,4 +174,11 @@ process.stdin.on("end", () => {
169
174
  }
170
175
 
171
176
  console.log("\n" + out.join("\n"));
177
+
178
+ // Tell the caller whether this is worth stopping for. Losing something is; building
179
+ // something is not. `process.exitCode` rather than `process.exit`, which can cut off
180
+ // the output above while it is still draining down a pipe.
181
+ const destructive =
182
+ groups.delete.length + groups.replace.length + supporting.delete + supporting.replace;
183
+ if (destructive) process.exitCode = 3;
172
184
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unoverse",
3
- "version": "0.1.76",
3
+ "version": "0.1.78",
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",