unoverse 0.1.61 → 0.1.63

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.
@@ -1,6 +1,54 @@
1
1
  #!/usr/bin/env bash
2
2
  # unoverse deploy — deploy to production VM from local
3
3
 
4
+ # ── Provisioning, spoken plainly ─────────────────────────────────────────────
5
+ #
6
+ # `terraform apply` shows ~200 lines of attributes and asks for a typed "yes". That is
7
+ # the right ceremony for an infrastructure engineer and the wrong first impression for a
8
+ # developer, who needs to know what will exist and what it costs. So: plan into a file,
9
+ # summarise it (tfsummary.mjs), take ONE clear answer, apply that exact plan.
10
+ #
11
+ # Nothing is softened. Destroys are shouted, the full technical plan is one keystroke
12
+ # away, and the SAVED plan is what runs, so what was approved is what happens. Identical
13
+ # for every cloud: the summary reads the plan JSON, which is provider-agnostic.
14
+ _ground_apply() {
15
+ local cloud="$1" dir="$ROOT/infra/$cloud" tmp planfile rc
16
+ tmp=$(mktemp -d); planfile="$tmp/plan"
17
+
18
+ terraform -chdir="$dir" init -input=false >/dev/null 2>&1 || { fail "terraform init failed"; rm -rf "$tmp"; return 1; }
19
+
20
+ info "Working out what needs to change..."
21
+ terraform -chdir="$dir" plan -input=false -detailed-exitcode -out="$planfile" >"$tmp/plan.log" 2>&1
22
+ rc=$?
23
+ case "$rc" in
24
+ 0) rm -rf "$tmp"; return 3 ;;
25
+ 2) : ;;
26
+ *) fail "Terraform could not plan the change:"; tail -20 "$tmp/plan.log" | sed 's/^/ /'; rm -rf "$tmp"; return 1 ;;
27
+ esac
28
+
29
+ if ! terraform -chdir="$dir" show -json "$planfile" 2>/dev/null | node "$GRAVITY_LIB/tfsummary.mjs"; then
30
+ terraform -chdir="$dir" show "$planfile"
31
+ fi
32
+
33
+ local REPLY
34
+ read -r -p " Go ahead? [y/N] ${DIM}(d for the full technical plan)${NC} " REPLY
35
+ if [[ "$REPLY" =~ ^[Dd]$ ]]; then
36
+ terraform -chdir="$dir" show "$planfile"
37
+ read -r -p " Go ahead? [y/N] " REPLY
38
+ fi
39
+ if [[ ! "$REPLY" =~ ^[Yy]$ ]]; then
40
+ info "Nothing was created. Run ${BOLD}unoverse deploy${NC} when you are ready"
41
+ rm -rf "$tmp"; return 1
42
+ fi
43
+
44
+ echo ""
45
+ info "Building. Managed databases take a few minutes on the provider's side"
46
+ echo ""
47
+ terraform -chdir="$dir" apply -input=false "$planfile" || { fail "The build did not finish. Re-run: unoverse deploy"; rm -rf "$tmp"; return 1; }
48
+ rm -rf "$tmp"
49
+ return 0
50
+ }
51
+
4
52
  cmd_deploy() {
5
53
 
6
54
  local env_prod="$ROOT/.env.production"
@@ -18,6 +66,27 @@ cmd_deploy() {
18
66
  done
19
67
  fi
20
68
 
69
+ # INFRA CHANGES SHIP TOO. If the ground has pending changes (edited tfvars, a new
70
+ # resource in main.tf from an update), deploy applies them first — through
71
+ # terraform's own plan-and-yes gate — then re-renders the env, since outputs may
72
+ # have changed. Without this, deploy only ever shipped images once a server
73
+ # existed, and infrastructure edits silently rotted.
74
+ local g_applied
75
+ if command -v terraform >/dev/null 2>&1; then
76
+ local g rc
77
+ for g in digitalocean aws; do
78
+ [ -d "$ROOT/infra/$g/.terraform" ] || continue
79
+ _ground_apply "$g"; rc=$?
80
+ [ "$rc" = "1" ] && exit 1
81
+ if [ "$rc" = "0" ]; then
82
+ terraform -chdir="$ROOT/infra/$g" output -raw env_production > "$env_prod" 2>/dev/null \
83
+ && ok "Refreshed .env.production from the ground"
84
+ g_applied=1
85
+ fi
86
+ break
87
+ done
88
+ fi
89
+
21
90
  # Missing .env.production is not an error if a ground has been applied — the
22
91
  # file is just a Terraform output, so render it ourselves. Reading state is
23
92
  # not wrapping terraform: apply stays the developer's explicit act.
@@ -113,8 +182,6 @@ cmd_deploy() {
113
182
  # for billable infrastructure; a [Y/n] in front of it was asking permission to ask
114
183
  # permission. Answering "no" is terraform's "no".
115
184
  echo ""
116
- info "Provisioning. Terraform shows the plan and asks for your ${BOLD}yes${NC} before creating anything"
117
- echo ""
118
185
  # Terraform is one static binary: install it directly from the official release
119
186
  # rather than sending anyone to brew (whose compile chain can demand Xcode Command
120
187
  # Line Tools updates for a tool that needs no compiling).
@@ -132,8 +199,7 @@ cmd_deploy() {
132
199
  rm -rf "$tf_dir"
133
200
  ok "terraform $(terraform version | head -1 | awk '{print $2}') installed"
134
201
  fi
135
- terraform -chdir="$ROOT/infra/$cloud" init -input=false >/dev/null 2>&1 || { fail "terraform init failed"; exit 1; }
136
- terraform -chdir="$ROOT/infra/$cloud" apply || { fail "apply did not complete. Re-run: unoverse deploy"; exit 1; }
202
+ _ground_apply "$cloud" || exit 1
137
203
  echo ""
138
204
  ok "Infrastructure is up. Shipping the platform onto it..."
139
205
  echo ""
@@ -0,0 +1,145 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The Terraform plan, in plain English.
4
+ *
5
+ * `terraform plan` prints ~200 lines of every attribute of every resource, most of them
6
+ * "(known after apply)". It is written for someone auditing infrastructure, and it is the
7
+ * first thing a developer deploying a universe ever sees. They need two facts: what will
8
+ * exist afterwards, and what it will cost. Everything else is noise at that moment.
9
+ *
10
+ * Reads `terraform show -json <planfile>` on stdin. Prints the headline resources with
11
+ * their size, counts the plumbing (database users, firewall rules, generated passwords)
12
+ * rather than listing it, and shouts about anything being destroyed.
13
+ *
14
+ * NOT A COST ORACLE. Prices are DigitalOcean's published monthly rates for the sizes this
15
+ * ground uses, so the developer sees an order of magnitude rather than a surprise. They
16
+ * move, so the total is labelled approximate and the real bill is the provider's.
17
+ */
18
+
19
+ const COLOR = !process.env.NO_COLOR;
20
+ const paint = (c) => (s) => (COLOR ? `\x1b[${c}m${s}\x1b[0m` : s);
21
+ const bold = paint("1");
22
+ const dim = paint("2");
23
+ const green = paint("32");
24
+ const red = paint("31");
25
+ const yellow = paint("33");
26
+
27
+ /** Monthly USD for the sizes these grounds use. Absent size → no guess. */
28
+ const PRICE = {
29
+ "s-1vcpu-1gb": 6, "s-1vcpu-2gb": 12, "s-2vcpu-2gb": 18, "s-2vcpu-4gb": 24,
30
+ "s-4vcpu-8gb": 48, "s-4vcpu-16gb-amd": 96, "s-8vcpu-16gb": 96,
31
+ "db-s-1vcpu-1gb": 15, "db-s-1vcpu-2gb": 30, "db-s-2vcpu-4gb": 60,
32
+ lb: 12,
33
+ "t3.large": 60, "t3.xlarge": 120, "db.t3.medium": 50, "cache.t3.micro": 12,
34
+ };
35
+
36
+ /** vCPU/RAM read out of a size slug, when it says so. */
37
+ function spec(size) {
38
+ if (!size) return "";
39
+ const m = /(\d+)vcpu-(\d+)gb/.exec(size);
40
+ return m ? `${m[1]} vCPU, ${m[2]} GB` : size;
41
+ }
42
+
43
+ /**
44
+ * What a resource IS, to a person. Returns null for plumbing: a database user, a
45
+ * connection pool and a generated password are all part of "a PostgreSQL database",
46
+ * and listing them separately turns one decision into eleven.
47
+ */
48
+ function describe(r) {
49
+ const a = r.change?.after ?? {};
50
+ switch (r.type) {
51
+ case "digitalocean_droplet":
52
+ return { what: "Server", detail: `${spec(a.size)} · ${a.region ?? ""}`, price: PRICE[a.size] };
53
+ case "digitalocean_database_cluster":
54
+ return a.engine === "redis"
55
+ ? { what: "Redis cache", detail: `${spec(a.size)} · v${a.version ?? ""}`, price: PRICE[a.size] }
56
+ : { what: "PostgreSQL database", detail: `${spec(a.size)} · v${a.version ?? ""}`, price: PRICE[a.size] };
57
+ case "digitalocean_loadbalancer":
58
+ return { what: "Load balancer", detail: "the public way in", price: PRICE.lb };
59
+ case "digitalocean_firewall":
60
+ return { what: "Firewall", detail: "admin ports limited to your IP" };
61
+ case "digitalocean_certificate":
62
+ return { what: "TLS certificate", detail: "HTTPS for your domain" };
63
+ case "digitalocean_record":
64
+ return { what: "DNS record", detail: a.name ? `${a.name}.${a.domain ?? ""}` : "" };
65
+ case "aws_instance":
66
+ return { what: "Server", detail: a.instance_type ?? "", price: PRICE[a.instance_type] };
67
+ case "aws_db_instance":
68
+ return { what: "PostgreSQL database", detail: a.instance_class ?? "", price: PRICE[a.instance_class] };
69
+ case "aws_elasticache_cluster":
70
+ case "aws_elasticache_replication_group":
71
+ return { what: "Redis cache", detail: a.node_type ?? "", price: PRICE[a.node_type] };
72
+ case "aws_lb":
73
+ return { what: "Load balancer", detail: "the public way in", price: 18 };
74
+ case "aws_cognito_user_pool":
75
+ return { what: "Login (Cognito)", detail: "who may use your universe" };
76
+ case "aws_acm_certificate":
77
+ return { what: "TLS certificate", detail: "HTTPS for your domain" };
78
+ default:
79
+ return null; // plumbing: counted, not listed
80
+ }
81
+ }
82
+
83
+ const ACTION = { create: "Creating", update: "Changing", delete: "Destroying", "replace": "Replacing" };
84
+
85
+ function action(ch) {
86
+ const acts = ch.actions ?? [];
87
+ if (acts.includes("delete") && acts.includes("create")) return "replace";
88
+ if (acts.includes("create")) return "create";
89
+ if (acts.includes("delete")) return "delete";
90
+ if (acts.includes("update")) return "update";
91
+ return null; // no-op / read
92
+ }
93
+
94
+ let raw = "";
95
+ process.stdin.on("data", (d) => (raw += d));
96
+ process.stdin.on("end", () => {
97
+ let plan;
98
+ try {
99
+ plan = JSON.parse(raw);
100
+ } catch {
101
+ process.exit(2); // caller falls back to raw terraform output
102
+ }
103
+
104
+ const groups = { create: [], update: [], delete: [], replace: [] };
105
+ const supporting = { create: 0, update: 0, delete: 0, replace: 0 };
106
+
107
+ for (const r of plan.resource_changes ?? []) {
108
+ const act = action(r.change ?? {});
109
+ if (!act) continue;
110
+ const d = describe(r);
111
+ if (d) groups[act].push(d);
112
+ else supporting[act]++;
113
+ }
114
+
115
+ const total = Object.values(groups).flat().length + Object.values(supporting).reduce((a, b) => a + b, 0);
116
+ const out = [];
117
+ if (total === 0) {
118
+ console.log(`\n ${green("●")} Your infrastructure already matches. Nothing to change.\n`);
119
+ process.exit(0);
120
+ }
121
+
122
+ const width = Math.max(...Object.values(groups).flat().map((d) => d.what.length), 0) + 2;
123
+
124
+ for (const act of ["create", "update", "replace", "delete"]) {
125
+ const list = groups[act];
126
+ const extra = supporting[act];
127
+ if (!list.length && !extra) continue;
128
+ const colour = act === "delete" || act === "replace" ? red : act === "update" ? yellow : green;
129
+ out.push(` ${bold(ACTION[act])}${act === "delete" ? red(" (this removes things that exist)") : ""}`);
130
+ for (const d of list) {
131
+ const price = d.price ? dim(` ~$${d.price}/mo`) : "";
132
+ out.push(` ${colour("●")} ${d.what.padEnd(width)}${dim(d.detail)}${price}`);
133
+ }
134
+ if (extra) out.push(` ${dim(`plus ${extra} supporting ${extra === 1 ? "resource" : "resources"} (users, rules, keys)`)}`);
135
+ out.push("");
136
+ }
137
+
138
+ const monthly = Object.values(groups).flat().reduce((sum, d) => sum + (d.price ?? 0), 0);
139
+ if (monthly) {
140
+ out.push(` ${bold("Roughly")} ${bold(`$${monthly}/month`)} ${dim("at your provider's current prices, billed by them, not by unoverse")}`);
141
+ out.push("");
142
+ }
143
+
144
+ console.log("\n" + out.join("\n"));
145
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unoverse",
3
- "version": "0.1.61",
3
+ "version": "0.1.63",
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",