unoverse 0.1.87 → 0.1.89
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/bin/unoverse.mjs +1 -1
- package/operator/ansible/playbooks/db-setup.yml +6 -5
- package/operator/infra/digitalocean/main.tf +7 -3
- package/operator/infra/digitalocean/outputs.tf +7 -0
- package/operator/lib/deploy.sh +175 -18
- package/operator/lib/help.sh +1 -0
- package/operator/lib/start.sh +6 -0
- package/operator/operator.sh +1 -0
- package/package.json +1 -1
package/bin/unoverse.mjs
CHANGED
|
@@ -58,7 +58,7 @@ const UNIVERSE = findUniverse();
|
|
|
58
58
|
// type it to update the CLI far more often than to refresh a local universe's images,
|
|
59
59
|
// and that refresh now belongs to `start --pull`.
|
|
60
60
|
const OPERATOR_COMMANDS = new Set([
|
|
61
|
-
"start", "stop", "check", "logs", "deploy", "destroy",
|
|
61
|
+
"start", "stop", "check", "logs", "deploy", "destroy", "db-allow",
|
|
62
62
|
// kept working, not advertised
|
|
63
63
|
"ground", "dev", "build", "publish", "init",
|
|
64
64
|
]);
|
|
@@ -90,20 +90,21 @@
|
|
|
90
90
|
# terraform at deploy time and is never written to the server.
|
|
91
91
|
- name: "[3b/5] Grant the universe user rights on its own schema"
|
|
92
92
|
command: >
|
|
93
|
-
docker compose exec -T -e NODE_TLS_REJECT_UNAUTHORIZED=0 -e ADMIN_URL={{ pg_admin_url }} unoverse node -e
|
|
93
|
+
docker compose exec -T -e NODE_TLS_REJECT_UNAUTHORIZED=0 -e ADMIN_URL={{ pg_admin_url }} -e PG_USER={{ pg_user | default('universe') }} unoverse node -e
|
|
94
94
|
"const{Client}=require('pg');
|
|
95
95
|
const c=new Client({connectionString:process.env.ADMIN_URL,ssl:{rejectUnauthorized:false}});
|
|
96
96
|
(async()=>{
|
|
97
97
|
await c.connect();
|
|
98
|
-
|
|
99
|
-
await c.query('GRANT ALL ON
|
|
100
|
-
await c.query('GRANT ALL ON ALL
|
|
98
|
+
const u = JSON.stringify(process.env.PG_USER).replace(/^\"|\"$/g,'');
|
|
99
|
+
await c.query('GRANT ALL ON SCHEMA public TO \"'+u+'\"');
|
|
100
|
+
await c.query('GRANT ALL ON ALL TABLES IN SCHEMA public TO \"'+u+'\"');
|
|
101
|
+
await c.query('GRANT ALL ON ALL SEQUENCES IN SCHEMA public TO \"'+u+'\"');
|
|
101
102
|
await c.end();
|
|
102
103
|
console.log('granted');
|
|
103
104
|
})().catch(e=>{console.error(e.message);process.exit(1)})"
|
|
104
105
|
args:
|
|
105
106
|
chdir: /opt/gravity
|
|
106
|
-
when: (pg_admin_url | default('')) | length > 0
|
|
107
|
+
when: (pg_admin_url | default('')) | length > 0 and (pg_user | default('')) | length > 0
|
|
107
108
|
no_log: true
|
|
108
109
|
register: grant_result
|
|
109
110
|
|
|
@@ -272,16 +272,20 @@ resource "digitalocean_database_cluster" "pg" {
|
|
|
272
272
|
node_count = 1
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
+
# NAMED AFTER THE UNIVERSE, not "universe". A cluster can host several universes — that
|
|
276
|
+
# is the whole point of adopting one rather than provisioning per stack — and a hardcoded
|
|
277
|
+
# name means the second one either collides with the first or, worse, attaches to it and
|
|
278
|
+
# silently shares its data. Everything else on this ground is already ${var.name}-prefixed.
|
|
275
279
|
resource "digitalocean_database_db" "universe" {
|
|
276
280
|
count = local.pg_managed ? 1 : 0
|
|
277
281
|
cluster_id = local.pg_cluster_id
|
|
278
|
-
name =
|
|
282
|
+
name = var.name
|
|
279
283
|
}
|
|
280
284
|
|
|
281
285
|
resource "digitalocean_database_user" "universe" {
|
|
282
286
|
count = local.pg_managed ? 1 : 0
|
|
283
287
|
cluster_id = local.pg_cluster_id
|
|
284
|
-
name =
|
|
288
|
+
name = var.name
|
|
285
289
|
|
|
286
290
|
# NEVER UPDATE THIS USER IN PLACE. Provider 2.96 grew a `settings` block (Kafka and
|
|
287
291
|
# OpenSearch ACLs) and an update path that fires whenever it sees any diff on the
|
|
@@ -305,7 +309,7 @@ resource "digitalocean_database_user" "universe" {
|
|
|
305
309
|
resource "digitalocean_database_connection_pool" "universe" {
|
|
306
310
|
count = local.pg_managed ? 1 : 0
|
|
307
311
|
cluster_id = local.pg_cluster_id
|
|
308
|
-
name = "
|
|
312
|
+
name = "${var.name}-pool"
|
|
309
313
|
mode = "transaction"
|
|
310
314
|
size = local.s.pgbouncer
|
|
311
315
|
db_name = digitalocean_database_db.universe[0].name
|
|
@@ -59,6 +59,13 @@ output "api_url" {
|
|
|
59
59
|
value = local.has_domain ? "https://${local.api_host}" : "http://${digitalocean_loadbalancer.public.ip}"
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
# The database user this universe owns. db-setup grants it rights on its own schema, and
|
|
63
|
+
# the name follows var.name now, so the playbook can no longer assume "universe".
|
|
64
|
+
output "pg_user" {
|
|
65
|
+
value = length(digitalocean_database_user.universe) > 0 ? digitalocean_database_user.universe[0].name : ""
|
|
66
|
+
description = "This universe's database user, when the ground manages one"
|
|
67
|
+
}
|
|
68
|
+
|
|
62
69
|
# Read by deploy for the one-time schema grant, never written to the server. Empty when
|
|
63
70
|
# the database is BYO: somebody else's cluster, whose permissions are theirs to run.
|
|
64
71
|
output "pg_admin_url" {
|
package/operator/lib/deploy.sh
CHANGED
|
@@ -26,6 +26,123 @@
|
|
|
26
26
|
# stale rule behind — and worse, terraform's own destroy of an authoritative firewall
|
|
27
27
|
# resource PUT an EMPTY list and locked the operator out of a database it had borrowed.
|
|
28
28
|
# Both directions now touch exactly one rule and never the list.
|
|
29
|
+
# Keep the DEVELOPER'S OWN MACHINE able to reach an adopted database, across networks.
|
|
30
|
+
#
|
|
31
|
+
# A managed cluster's trusted sources are a list of IP addresses, and a laptop's address is
|
|
32
|
+
# not a stable thing: a different office, a hotspot, a train, and local `npm run dev` dies
|
|
33
|
+
# on "Connection terminated unexpectedly" ten seconds into boot, with nothing on screen
|
|
34
|
+
# connecting that to the network you joined this morning. The droplet's own firewall already
|
|
35
|
+
# follows the operator around (_ensure_ground_config re-checks admin_cidr every deploy);
|
|
36
|
+
# this is the same idea for the one rule that lets a laptop in.
|
|
37
|
+
#
|
|
38
|
+
# IT ONLY EVER REMOVES ITS OWN. The addresses it added are recorded in .unoverse/trusted-ips,
|
|
39
|
+
# and nothing absent from that file is touched — the operator's other machines, their
|
|
40
|
+
# colleagues, their CI, their other droplets and apps all survive untouched. That rule
|
|
41
|
+
# exists because the opposite mistake is what wiped this exact cluster's firewall once
|
|
42
|
+
# already: an authoritative write that assumed the whole list was ours to own.
|
|
43
|
+
_operator_db_access() {
|
|
44
|
+
local cloud="$1" dir="$ROOT/infra/$cloud" cluster
|
|
45
|
+
cluster=$(grep -E '^existing_pg_cluster_name[[:space:]]*=' "$dir/terraform.tfvars" 2>/dev/null | sed -E 's/.*"([^"]+)".*/\1/')
|
|
46
|
+
[ -n "$cluster" ] || return 0
|
|
47
|
+
[ -n "${DIGITALOCEAN_TOKEN:-}" ] || return 0
|
|
48
|
+
mkdir -p "$ROOT/.unoverse"
|
|
49
|
+
|
|
50
|
+
node - "$cluster" "$ROOT/.unoverse/trusted-ips" <<'NODE'
|
|
51
|
+
const [cluster, ledgerPath] = process.argv.slice(2);
|
|
52
|
+
const fs = require("fs");
|
|
53
|
+
const T = process.env.DIGITALOCEAN_TOKEN;
|
|
54
|
+
const H = { Authorization: `Bearer ${T}`, "Content-Type": "application/json" };
|
|
55
|
+
const api = (p, o = {}) => fetch(`https://api.digitalocean.com/v2${p}`, { headers: H, ...o });
|
|
56
|
+
const read = () => { try { return fs.readFileSync(ledgerPath, "utf8").split("\n").map(s => s.trim()).filter(Boolean); } catch { return []; } };
|
|
57
|
+
|
|
58
|
+
(async () => {
|
|
59
|
+
const ip = (await (await fetch("https://api.ipify.org", { signal: AbortSignal.timeout(5000) })).text()).trim();
|
|
60
|
+
if (!/^\d+\.\d+\.\d+\.\d+$/.test(ip)) return;
|
|
61
|
+
|
|
62
|
+
const list = await (await api("/databases")).json();
|
|
63
|
+
const db = (list.databases || []).find((d) => d.name === cluster);
|
|
64
|
+
if (!db) return;
|
|
65
|
+
|
|
66
|
+
const fw = await (await api(`/databases/${db.id}/firewall`)).json();
|
|
67
|
+
let rules = (fw.rules || []).map((r) => ({ type: r.type, value: r.value }));
|
|
68
|
+
|
|
69
|
+
const ours = read(); // only these may be removed
|
|
70
|
+
const stale = ours.filter((v) => v !== ip);
|
|
71
|
+
const had = rules.some((r) => r.type === "ip_addr" && r.value === ip);
|
|
72
|
+
// SAY SO EVEN WHEN NOTHING CHANGES. Returning silently made the command look like it had
|
|
73
|
+
// failed: the developer typed it because they could not connect, and got a blank line.
|
|
74
|
+
if (had && stale.length === 0) {
|
|
75
|
+
fs.writeFileSync(ledgerPath, ip + "\n");
|
|
76
|
+
console.log(` \x1b[32m✓\x1b[0m This machine (${ip}) can already reach ${cluster} \x1b[2m(nothing changed)\x1b[0m`);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
rules = rules.filter((r) => !(r.type === "ip_addr" && stale.includes(r.value)));
|
|
81
|
+
if (!had) rules.push({ type: "ip_addr", value: ip });
|
|
82
|
+
|
|
83
|
+
const res = await api(`/databases/${db.id}/firewall`, { method: "PUT", body: JSON.stringify({ rules }) });
|
|
84
|
+
if (!res.ok) {
|
|
85
|
+
console.log(` \x1b[33m!\x1b[0m Could not update ${cluster}'s trusted sources — add ${ip} by hand`);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
fs.writeFileSync(ledgerPath, ip + "\n");
|
|
89
|
+
console.log(` \x1b[32m✓\x1b[0m This machine (${ip}) may reach ${cluster} \x1b[2m(${rules.length} trusted sources, only ours changed)\x1b[0m`);
|
|
90
|
+
})().catch(() => {});
|
|
91
|
+
NODE
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
# unoverse db-allow — let THIS machine reach this universe's database.
|
|
95
|
+
#
|
|
96
|
+
# Typed, never automatic. It changes a live database's network ACL, and doing that as a
|
|
97
|
+
# side effect of `start` meant a coffee shop's shared address quietly joined a production
|
|
98
|
+
# cluster's trusted sources. Typing it is the consent.
|
|
99
|
+
#
|
|
100
|
+
# What makes it safe to hand a developer: the DigitalOcean token gates it, so nobody
|
|
101
|
+
# without your cloud credential can run it at all, and the database still demands its own
|
|
102
|
+
# password afterwards. This opens a door in the network layer; it does not open the
|
|
103
|
+
# database.
|
|
104
|
+
cmd_db_allow() {
|
|
105
|
+
local cloud="" g
|
|
106
|
+
for g in digitalocean aws; do
|
|
107
|
+
[ -f "$ROOT/infra/$g/terraform.tfvars" ] && { cloud="$g"; break; }
|
|
108
|
+
done
|
|
109
|
+
if [ -z "$cloud" ]; then
|
|
110
|
+
fail "No ground here. There is no database to reach"
|
|
111
|
+
return 1
|
|
112
|
+
fi
|
|
113
|
+
|
|
114
|
+
local cluster
|
|
115
|
+
cluster=$(grep -E '^existing_pg_cluster_name[[:space:]]*=' "$ROOT/infra/$cloud/terraform.tfvars" 2>/dev/null | sed -E 's/.*"([^"]+)".*/\1/')
|
|
116
|
+
|
|
117
|
+
# THE MONOREPO NEEDS THIS TOO, and it has no ground. The platform's own checkout is
|
|
118
|
+
# developed against a managed cluster named only in .env, so a ground-only lookup found
|
|
119
|
+
# nothing and the one place the developer actually types `npm run dev` was the one place
|
|
120
|
+
# this could not help. A cluster host is `<name>-do-user-...`, so the name is right there
|
|
121
|
+
# in DATABASE_URL.
|
|
122
|
+
if [ -z "$cluster" ]; then
|
|
123
|
+
cluster=$(grep -E '^DATABASE_URL=' "$ROOT/.env" 2>/dev/null | head -1 \
|
|
124
|
+
| sed -E 's|.*@([a-z0-9-]+)-do-user-[^.]*\..*|\1|; t; d')
|
|
125
|
+
fi
|
|
126
|
+
|
|
127
|
+
if [ -z "$cluster" ]; then
|
|
128
|
+
ok "This database has no trusted-source list to join"
|
|
129
|
+
info "Nothing to do — you can already reach it"
|
|
130
|
+
return 0
|
|
131
|
+
fi
|
|
132
|
+
|
|
133
|
+
_ground_credentials
|
|
134
|
+
if [ -z "${DIGITALOCEAN_TOKEN:-}" ]; then
|
|
135
|
+
fail "No DigitalOcean credential. Run ${BOLD}unoverse deploy${NC} once, or ${BOLD}doctl auth init${NC}"
|
|
136
|
+
return 1
|
|
137
|
+
fi
|
|
138
|
+
|
|
139
|
+
echo ""
|
|
140
|
+
_operator_db_access "$cloud"
|
|
141
|
+
echo ""
|
|
142
|
+
info "Run this again whenever you change network"
|
|
143
|
+
echo ""
|
|
144
|
+
}
|
|
145
|
+
|
|
29
146
|
_adopted_db_access() {
|
|
30
147
|
local cloud="$1" dir="$ROOT/infra/$cloud" cluster droplet_id
|
|
31
148
|
local mode="$2"
|
|
@@ -143,28 +260,68 @@ _ensure_ground_config() {
|
|
|
143
260
|
fi
|
|
144
261
|
fi
|
|
145
262
|
|
|
146
|
-
|
|
147
|
-
#
|
|
148
|
-
#
|
|
149
|
-
#
|
|
263
|
+
# PRODUCTION'S DATABASE IS NOT THE ONE IN .env, AND THE QUESTION MUST SAY SO.
|
|
264
|
+
#
|
|
265
|
+
# `.env` holds the DEVELOPMENT database — what `npm run dev` talks to on the laptop. The
|
|
266
|
+
# deployed universe gets its own, so a developer cannot break production by experimenting
|
|
267
|
+
# locally. That separation is right and is the default.
|
|
268
|
+
#
|
|
269
|
+
# What was wrong was the words. This asked "You already have a database — use it?", where
|
|
270
|
+
# "it" meant the CLUSTER and "use" meant "create a new database inside it". A developer
|
|
271
|
+
# who had typed a DATABASE_URL during setup read that as "use the database I gave you",
|
|
272
|
+
# answered yes, and got an empty one — then could not find their 21 workflows and
|
|
273
|
+
# reasonably concluded the deploy had lost them. Nothing was lost; the sentence was.
|
|
274
|
+
#
|
|
275
|
+
# So: name the cluster, say a NEW database goes in it, and say what happens to the one in
|
|
276
|
+
# .env. Anyone wanting production to share the development database sets byo_postgres_url
|
|
277
|
+
# deliberately, which is a different and much louder act.
|
|
150
278
|
if [ "$cloud" = "digitalocean" ] \
|
|
151
|
-
&& grep -q '^#[[:space:]]*existing_pg_cluster_name' "$tfv" 2>/dev/null \
|
|
152
279
|
&& ! grep -q '^existing_pg_cluster_name' "$tfv" 2>/dev/null \
|
|
153
280
|
&& ! grep -q '^byo_postgres_url' "$tfv" 2>/dev/null; then
|
|
154
|
-
local found
|
|
155
|
-
found=$(grep '^#[[:space:]]*existing_pg_cluster_name' "$tfv" | sed -E 's/.*"([^"]+)".*/\1/')
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
281
|
+
local found env_db db_name
|
|
282
|
+
found=$(grep '^#[[:space:]]*existing_pg_cluster_name' "$tfv" 2>/dev/null | sed -E 's/.*"([^"]+)".*/\1/')
|
|
283
|
+
env_db=$(grep -E '^DATABASE_URL=' "$ROOT/.env" 2>/dev/null | head -1 | cut -d= -f2-)
|
|
284
|
+
db_name=$(echo "$env_db" | sed -E 's|.*/([^/?]+)(\?.*)?$|\1|')
|
|
285
|
+
|
|
286
|
+
echo ""
|
|
287
|
+
echo -e " ${CYAN}${BOLD}Which database should the DEPLOYED universe use?${NC}"
|
|
288
|
+
echo ""
|
|
289
|
+
echo -e " ${DIM}Your .env is your local development database and does not change.${NC}"
|
|
290
|
+
echo ""
|
|
291
|
+
local opt_new_db="" opt_share=""
|
|
292
|
+
[ -n "$found" ] && opt_new_db="Its own new database on ${found} free"
|
|
293
|
+
[ -n "$db_name" ] && opt_share="The same one you develop against ${db_name}"
|
|
294
|
+
|
|
295
|
+
# Order is the recommendation. Its own database first: production and development stay
|
|
296
|
+
# independent, which is what almost everyone wants and what nobody regrets.
|
|
297
|
+
if [ -n "$opt_new_db" ] && [ -n "$opt_share" ]; then
|
|
298
|
+
pick_option "$opt_new_db" "$opt_share" "A new cluster of its own ~\$15/month"
|
|
299
|
+
elif [ -n "$opt_new_db" ]; then
|
|
300
|
+
pick_option "$opt_new_db" "A new cluster of its own ~\$15/month"
|
|
301
|
+
elif [ -n "$opt_share" ]; then
|
|
302
|
+
pick_option "$opt_share" "A new cluster of its own ~\$15/month"
|
|
303
|
+
else
|
|
304
|
+
PICKED=99 # nothing to reuse: the ground provisions a cluster, no question worth asking
|
|
167
305
|
fi
|
|
306
|
+
|
|
307
|
+
local choice=""
|
|
308
|
+
case "$PICKED" in
|
|
309
|
+
0) [ -n "$opt_new_db" ] && choice="own" || choice="share" ;;
|
|
310
|
+
1) [ -n "$opt_new_db" ] && [ -n "$opt_share" ] && choice="share" || choice="fresh" ;;
|
|
311
|
+
*) choice="fresh" ;;
|
|
312
|
+
esac
|
|
313
|
+
|
|
314
|
+
case "$choice" in
|
|
315
|
+
own)
|
|
316
|
+
node -e 'const fs=require("fs");const[f]=process.argv.slice(1);let s=fs.readFileSync(f,"utf8");s=s.replace(/^#\s*(existing_pg_cluster_name\s*=.*)$/m,"$1");fs.writeFileSync(f,s)' "$tfv"
|
|
317
|
+
ok "Production gets its own database on $found ${DIM}(your development data is untouched)${NC}"
|
|
318
|
+
;;
|
|
319
|
+
share)
|
|
320
|
+
node -e 'const fs=require("fs");const[f,v]=process.argv.slice(1);let s=fs.readFileSync(f,"utf8");s=s.replace(/^#?\s*byo_postgres_url\s*=.*$/m,"byo_postgres_url = "+JSON.stringify(v));fs.writeFileSync(f,s)' "$tfv" "$env_db"
|
|
321
|
+
ok "Production reads ${BOLD}$db_name${NC} ${DIM}(the same database you develop against — local changes are live)${NC}"
|
|
322
|
+
;;
|
|
323
|
+
*) ok "A new cluster will be created for production" ;;
|
|
324
|
+
esac
|
|
168
325
|
fi
|
|
169
326
|
|
|
170
327
|
_tf_fill docr_token DOCR_TOKEN "Registry access token (from your Unoverse admin)"
|
package/operator/lib/help.sh
CHANGED
|
@@ -24,6 +24,7 @@ cmd_help() {
|
|
|
24
24
|
echo -e " ${GREEN}logs${NC} What is it doing ${DIM}(unoverse logs <service> for one)${NC}"
|
|
25
25
|
echo -e " ${GREEN}deploy${NC} Ship it to a server ${DIM}(first run asks which cloud)${NC}"
|
|
26
26
|
echo -e " ${GREEN}destroy${NC} Take the deployment down ${DIM}(shows what goes, and what stays)${NC}"
|
|
27
|
+
echo -e " ${GREEN}db-allow${NC} Let this machine reach the database ${DIM}(run it when you change network)${NC}"
|
|
27
28
|
echo ""
|
|
28
29
|
# Owner-only lane. Printed ONLY when publish.sh is present, so a starter kit never
|
|
29
30
|
# advertises a command it does not have (sync-starter.sh deletes that file).
|
package/operator/lib/start.sh
CHANGED
|
@@ -52,6 +52,12 @@ pull_missing_images() {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
cmd_start() {
|
|
55
|
+
# NO FIREWALL CHANGE HERE. Starting a dev server briefly did this automatically, so a
|
|
56
|
+
# laptop that had moved could always reach its database. It also meant every `start` on
|
|
57
|
+
# café or hotel Wi-Fi silently added THAT network's shared egress address to a production
|
|
58
|
+
# cluster's trusted sources, where it stayed until the next run. An ACL change is a
|
|
59
|
+
# deliberate act: `unoverse db-allow`, typed, when you move network.
|
|
60
|
+
|
|
55
61
|
# Login to registry if DOCR_TOKEN is set
|
|
56
62
|
local docr_token
|
|
57
63
|
docr_token=$(grep "^DOCR_TOKEN=" "$ROOT/.env" 2>/dev/null | cut -d'=' -f2-)
|
package/operator/operator.sh
CHANGED
|
@@ -66,6 +66,7 @@ case "${1:-}" in
|
|
|
66
66
|
dev) cmd_dev ;;
|
|
67
67
|
ground) shift; cmd_ground "$@" ;;
|
|
68
68
|
destroy) cmd_destroy ;;
|
|
69
|
+
db-allow) cmd_db_allow ;;
|
|
69
70
|
refresh-images)
|
|
70
71
|
# internal: `unoverse update` runs this after updating the CLI. Pull newer images
|
|
71
72
|
# if the registry has them, and recreate only what is already running — an update
|
package/package.json
CHANGED