unoverse 0.1.75 → 0.1.77
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 ─────────────────────────────────
|
|
@@ -261,8 +261,18 @@ resource "digitalocean_database_connection_pool" "universe" {
|
|
|
261
261
|
user = digitalocean_database_user.universe[0].name
|
|
262
262
|
}
|
|
263
263
|
|
|
264
|
+
# ONLY A CLUSTER THIS STACK CREATED. `digitalocean_database_firewall` is AUTHORITATIVE:
|
|
265
|
+
# it replaces the whole trusted-sources list rather than adding to it. Applied to an
|
|
266
|
+
# ADOPTED cluster it therefore deleted every rule the account already had — the
|
|
267
|
+
# operator's own IP, their other droplets, their App Platform apps — and locked them out
|
|
268
|
+
# of a database this universe merely borrows. It happened, on 2026-08-01.
|
|
269
|
+
#
|
|
270
|
+
# So terraform owns the firewall only when it owns the cluster (`provision_pg`). For an
|
|
271
|
+
# adopted cluster the CLI appends this droplet to the existing rules instead
|
|
272
|
+
# (scripts/lib/deploy.sh), which is additive and leaves everything else alone. A universe
|
|
273
|
+
# never takes ownership of a database it did not create.
|
|
264
274
|
resource "digitalocean_database_firewall" "pg" {
|
|
265
|
-
count = local.
|
|
275
|
+
count = local.provision_pg ? 1 : 0
|
|
266
276
|
cluster_id = local.pg_cluster_id
|
|
267
277
|
rule {
|
|
268
278
|
type = "droplet"
|
package/operator/lib/deploy.sh
CHANGED
|
@@ -14,6 +14,74 @@
|
|
|
14
14
|
# Terraform is one static binary: install it from the official release rather than
|
|
15
15
|
# sending anyone to brew, whose compile chain can demand Xcode Command Line Tools
|
|
16
16
|
# updates for a tool that needs no compiling.
|
|
17
|
+
# Give this universe's droplet access to an ADOPTED database, additively.
|
|
18
|
+
#
|
|
19
|
+
# Terraform cannot do this safely: `digitalocean_database_firewall` replaces the whole
|
|
20
|
+
# trusted-sources list, so pointing it at a borrowed cluster deletes the operator's own
|
|
21
|
+
# IP, their other droplets and their apps. Read the rules, add ours if missing, write
|
|
22
|
+
# them back. Nothing else moves, and running it twice changes nothing.
|
|
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() {
|
|
30
|
+
local cloud="$1" dir="$ROOT/infra/$cloud" cluster droplet_id
|
|
31
|
+
local mode="$2"
|
|
32
|
+
cluster=$(grep -E '^existing_pg_cluster_name[[:space:]]*=' "$dir/terraform.tfvars" 2>/dev/null | sed -E 's/.*"([^"]+)".*/\1/')
|
|
33
|
+
[ -n "$cluster" ] || return 0
|
|
34
|
+
[ -n "${DIGITALOCEAN_TOKEN:-}" ] || return 0
|
|
35
|
+
droplet_id="${3:-$(terraform -chdir="$dir" state show digitalocean_droplet.app 2>/dev/null | awk -F'"' '/^ *id *=/{print $2; exit}')}"
|
|
36
|
+
[ -n "$droplet_id" ] || return 0
|
|
37
|
+
|
|
38
|
+
node - "$cluster" "$droplet_id" "$mode" <<'NODE'
|
|
39
|
+
const [cluster, droplet, mode] = process.argv.slice(2);
|
|
40
|
+
const T = process.env.DIGITALOCEAN_TOKEN;
|
|
41
|
+
const H = { Authorization: `Bearer ${T}`, "Content-Type": "application/json" };
|
|
42
|
+
const api = (p, o = {}) => fetch(`https://api.digitalocean.com/v2${p}`, { headers: H, ...o });
|
|
43
|
+
(async () => {
|
|
44
|
+
const list = await (await api("/databases")).json();
|
|
45
|
+
const db = (list.databases || []).find((d) => d.name === cluster);
|
|
46
|
+
if (!db) return;
|
|
47
|
+
const fw = await (await api(`/databases/${db.id}/firewall`)).json();
|
|
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
|
+
}
|
|
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";
|
|
60
|
+
console.log(res.ok
|
|
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`);
|
|
63
|
+
})().catch(() => {});
|
|
64
|
+
NODE
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
# The cloud credential terraform needs, from where the CLI already put it.
|
|
68
|
+
#
|
|
69
|
+
# By design the DO token lives in doctl's config and never in a repo file (main.tf: an
|
|
70
|
+
# empty var falls through to DIGITALOCEAN_TOKEN). This used to sit INSIDE cmd_deploy, so
|
|
71
|
+
# `unoverse destroy` ran with no credential at all and every API call came back 401 —
|
|
72
|
+
# the same class of bug as the Postgres question living in one branch of two. Anything
|
|
73
|
+
# that talks to a ground calls this first.
|
|
74
|
+
_ground_credentials() {
|
|
75
|
+
[ -n "${DIGITALOCEAN_TOKEN:-}" ] && return 0
|
|
76
|
+
local _docfg
|
|
77
|
+
for _docfg in "$HOME/Library/Application Support/doctl/config.yaml" "$HOME/.config/doctl/config.yaml"; do
|
|
78
|
+
[ -f "$_docfg" ] || continue
|
|
79
|
+
DIGITALOCEAN_TOKEN=$(awk '/access-token:/{print $2; exit}' "$_docfg")
|
|
80
|
+
if [ -n "$DIGITALOCEAN_TOKEN" ]; then export DIGITALOCEAN_TOKEN; return 0; fi
|
|
81
|
+
done
|
|
82
|
+
return 0
|
|
83
|
+
}
|
|
84
|
+
|
|
17
85
|
_ensure_terraform() {
|
|
18
86
|
command -v terraform >/dev/null 2>&1 && return 0
|
|
19
87
|
local REPLY tf_arch tf_os tf_v="1.9.8" tf_dir
|
|
@@ -209,18 +277,7 @@ cmd_deploy() {
|
|
|
209
277
|
|
|
210
278
|
local env_prod="$ROOT/.env.production"
|
|
211
279
|
|
|
212
|
-
|
|
213
|
-
# in a repo file (main.tf: empty var falls through to DIGITALOCEAN_TOKEN). deploy
|
|
214
|
-
# authenticated doctl, so deploy passes the token on — process env only, no file.
|
|
215
|
-
# Without this, apply planned 6 resources and then 401'd on an empty credential.
|
|
216
|
-
if [ -z "${DIGITALOCEAN_TOKEN:-}" ]; then
|
|
217
|
-
local _docfg
|
|
218
|
-
for _docfg in "$HOME/Library/Application Support/doctl/config.yaml" "$HOME/.config/doctl/config.yaml"; do
|
|
219
|
-
[ -f "$_docfg" ] || continue
|
|
220
|
-
DIGITALOCEAN_TOKEN=$(awk '/access-token:/{print $2; exit}' "$_docfg")
|
|
221
|
-
if [ -n "$DIGITALOCEAN_TOKEN" ]; then export DIGITALOCEAN_TOKEN; break; fi
|
|
222
|
-
done
|
|
223
|
-
fi
|
|
280
|
+
_ground_credentials
|
|
224
281
|
|
|
225
282
|
# ── ONE FLOW ────────────────────────────────────────────────────────────────
|
|
226
283
|
# Which ground, is it configured, plan it, apply it, ship it. In that order, every
|
|
@@ -269,6 +326,8 @@ cmd_deploy() {
|
|
|
269
326
|
exit 1
|
|
270
327
|
fi
|
|
271
328
|
|
|
329
|
+
_adopted_db_access "$cloud" grant
|
|
330
|
+
|
|
272
331
|
|
|
273
332
|
# Read deploy target from .env.production
|
|
274
333
|
local deploy_host deploy_user
|
package/operator/lib/destroy.sh
CHANGED
|
@@ -34,6 +34,7 @@ cmd_destroy() {
|
|
|
34
34
|
return 1
|
|
35
35
|
fi
|
|
36
36
|
|
|
37
|
+
_ground_credentials
|
|
37
38
|
_ensure_terraform || return 1
|
|
38
39
|
|
|
39
40
|
local pretty
|
|
@@ -72,7 +73,15 @@ cmd_destroy() {
|
|
|
72
73
|
name="${name:-universe}"
|
|
73
74
|
|
|
74
75
|
echo ""
|
|
75
|
-
|
|
76
|
+
# Say what is actually lost. With a reused cluster the DATABASE survives, and claiming
|
|
77
|
+
# otherwise makes the warning untrue in the commonest case — which teaches people to
|
|
78
|
+
# skim warnings.
|
|
79
|
+
if [ -n "$kept" ]; then
|
|
80
|
+
echo -e " ${RED}This deletes the server and everything on its disk. It cannot be undone.${NC}"
|
|
81
|
+
echo -e " ${DIM}Your database and its contents survive in $kept.${NC}"
|
|
82
|
+
else
|
|
83
|
+
echo -e " ${RED}This deletes the server, the database and all their data. It cannot be undone.${NC}"
|
|
84
|
+
fi
|
|
76
85
|
echo ""
|
|
77
86
|
local typed
|
|
78
87
|
read -r -p " Type the universe name to confirm ($name): " typed
|
|
@@ -82,6 +91,14 @@ cmd_destroy() {
|
|
|
82
91
|
return 1
|
|
83
92
|
fi
|
|
84
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
|
+
|
|
85
102
|
echo ""
|
|
86
103
|
info "Taking it down..."
|
|
87
104
|
echo ""
|
|
@@ -48,7 +48,10 @@ function spec(size) {
|
|
|
48
48
|
* and listing them separately turns one decision into eleven.
|
|
49
49
|
*/
|
|
50
50
|
function describe(r) {
|
|
51
|
-
|
|
51
|
+
// BEFORE, when there is no after. A destroy plan has `after: null` — every resource
|
|
52
|
+
// is being removed — so reading only `after` printed "Server · " with no size or
|
|
53
|
+
// region: the summary of a teardown, describing nothing.
|
|
54
|
+
const a = r.change?.after ?? r.change?.before ?? {};
|
|
52
55
|
switch (r.type) {
|
|
53
56
|
case "digitalocean_droplet":
|
|
54
57
|
return { what: "Server", detail: `${spec(a.size)} · ${a.region ?? ""}`, price: PRICE[a.size] };
|
package/package.json
CHANGED