unoverse 0.1.62 → 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.
- package/operator/lib/deploy.sh +54 -10
- package/operator/lib/tfsummary.mjs +145 -0
- package/package.json +1 -1
package/operator/lib/deploy.sh
CHANGED
|
@@ -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"
|
|
@@ -28,12 +76,11 @@ cmd_deploy() {
|
|
|
28
76
|
local g rc
|
|
29
77
|
for g in digitalocean aws; do
|
|
30
78
|
[ -d "$ROOT/infra/$g/.terraform" ] || continue
|
|
31
|
-
|
|
32
|
-
rc
|
|
33
|
-
if [ "$rc" = "
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
terraform -chdir="$ROOT/infra/$g" output -raw env_production > "$env_prod" 2>/dev/null && ok "Refreshed .env.production from the ground"
|
|
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"
|
|
37
84
|
g_applied=1
|
|
38
85
|
fi
|
|
39
86
|
break
|
|
@@ -135,8 +182,6 @@ cmd_deploy() {
|
|
|
135
182
|
# for billable infrastructure; a [Y/n] in front of it was asking permission to ask
|
|
136
183
|
# permission. Answering "no" is terraform's "no".
|
|
137
184
|
echo ""
|
|
138
|
-
info "Provisioning. Terraform shows the plan and asks for your ${BOLD}yes${NC} before creating anything"
|
|
139
|
-
echo ""
|
|
140
185
|
# Terraform is one static binary: install it directly from the official release
|
|
141
186
|
# rather than sending anyone to brew (whose compile chain can demand Xcode Command
|
|
142
187
|
# Line Tools updates for a tool that needs no compiling).
|
|
@@ -154,8 +199,7 @@ cmd_deploy() {
|
|
|
154
199
|
rm -rf "$tf_dir"
|
|
155
200
|
ok "terraform $(terraform version | head -1 | awk '{print $2}') installed"
|
|
156
201
|
fi
|
|
157
|
-
|
|
158
|
-
terraform -chdir="$ROOT/infra/$cloud" apply || { fail "apply did not complete. Re-run: unoverse deploy"; exit 1; }
|
|
202
|
+
_ground_apply "$cloud" || exit 1
|
|
159
203
|
echo ""
|
|
160
204
|
ok "Infrastructure is up. Shipping the platform onto it..."
|
|
161
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