unoverse 0.1.99 → 0.1.101
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/infra/aws/main.tf +13 -1
- package/operator/infra/aws/variables.tf +10 -0
- package/operator/lib/check.sh +82 -0
- package/operator/lib/deploy.sh +12 -14
- package/operator/lib/ground.sh +22 -2
- package/package.json +1 -1
|
@@ -242,10 +242,22 @@ data "aws_ami" "ubuntu" {
|
|
|
242
242
|
}
|
|
243
243
|
}
|
|
244
244
|
|
|
245
|
+
# The key pair, from the operator's own public key. AWS accepts an imported key, so nothing
|
|
246
|
+
# has to pre-exist in the account and no private key is ever downloaded or stored.
|
|
247
|
+
resource "aws_key_pair" "operator" {
|
|
248
|
+
count = var.operator_public_key != "" ? 1 : 0
|
|
249
|
+
key_name = "${var.name}-operator"
|
|
250
|
+
public_key = var.operator_public_key
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
locals {
|
|
254
|
+
key_name = var.operator_public_key != "" ? aws_key_pair.operator[0].key_name : var.ssh_key_name
|
|
255
|
+
}
|
|
256
|
+
|
|
245
257
|
resource "aws_instance" "app" {
|
|
246
258
|
ami = data.aws_ami.ubuntu.id
|
|
247
259
|
instance_type = local.s.instance
|
|
248
|
-
key_name =
|
|
260
|
+
key_name = local.key_name
|
|
249
261
|
vpc_security_group_ids = [aws_security_group.app.id]
|
|
250
262
|
|
|
251
263
|
root_block_device {
|
|
@@ -102,3 +102,13 @@ variable "marketplace_url" {
|
|
|
102
102
|
type = string
|
|
103
103
|
default = ""
|
|
104
104
|
}
|
|
105
|
+
|
|
106
|
+
# YOUR public key, uploaded as this universe's EC2 key pair. Deploying means ssh-ing from
|
|
107
|
+
# the machine that runs `unoverse deploy`, so the key that must work is the one that machine
|
|
108
|
+
# holds — not whichever pair happens to exist in the account. Empty falls back to
|
|
109
|
+
# ssh_key_name, for an operator with no local key.
|
|
110
|
+
variable "operator_public_key" {
|
|
111
|
+
description = "An ssh public key (the contents of ~/.ssh/id_ed25519.pub). Empty = use ssh_key_name instead."
|
|
112
|
+
type = string
|
|
113
|
+
default = ""
|
|
114
|
+
}
|
package/operator/lib/check.sh
CHANGED
|
@@ -1,4 +1,56 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
+
|
|
3
|
+
# Resources this universe is BILLED FOR that terraform does not know it owns.
|
|
4
|
+
# _cloud_orphans <ground> <universe-name>
|
|
5
|
+
# Echoes nothing when everything is tracked, "unknown" when the provider could not be
|
|
6
|
+
# reached (never a failure — an offline laptop is not an orphaned resource), or one line
|
|
7
|
+
# per untracked resource.
|
|
8
|
+
#
|
|
9
|
+
# It works because every resource is labelled with the universe it belongs to: AWS through
|
|
10
|
+
# default_tags (Universe=<name>), DigitalOcean through project membership. Without that
|
|
11
|
+
# label there is no question to ask, which is why the Canvas load balancer landing in the
|
|
12
|
+
# wrong DO project was worth fixing rather than tidying.
|
|
13
|
+
_cloud_orphans() {
|
|
14
|
+
local g="$1" uname_="$2" dir="$ROOT/infra/$1"
|
|
15
|
+
[ -n "$uname_" ] || { echo unknown; return 0; }
|
|
16
|
+
|
|
17
|
+
local state
|
|
18
|
+
state=$(terraform -chdir="$dir" show -json 2>/dev/null) || { echo unknown; return 0; }
|
|
19
|
+
[ -n "$state" ] || { echo unknown; return 0; }
|
|
20
|
+
|
|
21
|
+
local live
|
|
22
|
+
if [ "$g" = "aws" ]; then
|
|
23
|
+
command -v aws >/dev/null 2>&1 || { echo unknown; return 0; }
|
|
24
|
+
live=$(aws resourcegroupstaggingapi get-resources \
|
|
25
|
+
--tag-filters "Key=Universe,Values=$uname_" \
|
|
26
|
+
--query 'ResourceTagMappingList[].ResourceARN' --output text 2>/dev/null | tr '\t' '\n')
|
|
27
|
+
else
|
|
28
|
+
[ -n "${DIGITALOCEAN_TOKEN:-}" ] || { echo unknown; return 0; }
|
|
29
|
+
local pid
|
|
30
|
+
pid=$(doctl projects list --format ID,Name --no-header 2>/dev/null | awk -v n="$uname_" '$2==n{print $1}')
|
|
31
|
+
[ -n "$pid" ] || { echo unknown; return 0; }
|
|
32
|
+
live=$(doctl projects resources list "$pid" --format URN --no-header 2>/dev/null)
|
|
33
|
+
fi
|
|
34
|
+
[ -n "$live" ] || { echo ""; return 0; }
|
|
35
|
+
|
|
36
|
+
printf '%s' "$state" | node -e '
|
|
37
|
+
let s=""; process.stdin.on("data",d=>s+=d).on("end",()=>{
|
|
38
|
+
const live=(process.argv[1]||"").split("\n").map(x=>x.trim()).filter(Boolean);
|
|
39
|
+
let ids=new Set();
|
|
40
|
+
try {
|
|
41
|
+
const st=JSON.parse(s);
|
|
42
|
+
const walk=(m)=>{ (m.resources||[]).forEach(r=>{ if(!r.values) return;
|
|
43
|
+
["id","arn","urn"].forEach(k=>r.values[k]&&ids.add(String(r.values[k]))); });
|
|
44
|
+
(m.child_modules||[]).forEach(walk); };
|
|
45
|
+
if (st.values && st.values.root_module) walk(st.values.root_module);
|
|
46
|
+
} catch { process.exit(0); } // unreadable state is not an orphan claim
|
|
47
|
+
const known=[...ids];
|
|
48
|
+
const orphans=live.filter(a=>!ids.has(a) && !known.some(i=>i && a.includes(i)));
|
|
49
|
+
orphans.forEach(o=>console.log(o));
|
|
50
|
+
});
|
|
51
|
+
' "$live"
|
|
52
|
+
}
|
|
53
|
+
|
|
2
54
|
# unoverse check
|
|
3
55
|
|
|
4
56
|
cmd_check() {
|
|
@@ -86,6 +138,36 @@ cmd_check() {
|
|
|
86
138
|
fail "Canvas ${DIM}http://localhost:3001 → $canvas_code${NC}"
|
|
87
139
|
fi
|
|
88
140
|
|
|
141
|
+
# 6. NOTHING BILLING THAT TERRAFORM HAS LOST SIGHT OF.
|
|
142
|
+
#
|
|
143
|
+
# Terraform records what it creates as it creates it, so an interrupted apply does not
|
|
144
|
+
# duplicate anything — the next run continues from state. Two cases break that, and both
|
|
145
|
+
# cost money silently: a crash between the API call and the state write, and a lost or
|
|
146
|
+
# deleted state file, which orphans everything at once and makes it invisible to destroy.
|
|
147
|
+
#
|
|
148
|
+
# This is answerable because every resource is labelled with the universe it belongs to:
|
|
149
|
+
# AWS through default_tags, DigitalOcean through project membership. Ask the provider
|
|
150
|
+
# what it thinks is ours, ask terraform what it knows it made, and compare. A number on
|
|
151
|
+
# the health check is the difference between trusting that and verifying it.
|
|
152
|
+
local _g
|
|
153
|
+
for _g in aws digitalocean; do
|
|
154
|
+
[ -f "$ROOT/infra/$_g/terraform.tfvars" ] || continue
|
|
155
|
+
[ -d "$ROOT/infra/$_g/.terraform" ] || continue
|
|
156
|
+
total=$((total + 1))
|
|
157
|
+
local uname_ orphans
|
|
158
|
+
uname_=$(grep -E '^name[[:space:]]*=' "$ROOT/infra/$_g/terraform.tfvars" 2>/dev/null | sed -E 's/.*"([^"]+)".*/\1/')
|
|
159
|
+
orphans=$(_cloud_orphans "$_g" "$uname_")
|
|
160
|
+
case "$orphans" in
|
|
161
|
+
"") ok "Cloud resources ${DIM}($_g — every resource is tracked)${NC}"; pass=$((pass + 1)) ;;
|
|
162
|
+
unknown) ok "Cloud resources ${DIM}($_g — could not reach the provider, skipped)${NC}"; pass=$((pass + 1)) ;;
|
|
163
|
+
*)
|
|
164
|
+
fail "Cloud resources ${DIM}($_g — billing but NOT tracked by terraform)${NC}"
|
|
165
|
+
echo "$orphans" | while read -r o; do [ -n "$o" ] && echo -e " ${DIM}$o${NC}"; done
|
|
166
|
+
info " These will not be removed by ${BOLD}unoverse destroy $_g${NC}. Delete them in the console"
|
|
167
|
+
;;
|
|
168
|
+
esac
|
|
169
|
+
done
|
|
170
|
+
|
|
89
171
|
# Summary
|
|
90
172
|
echo ""
|
|
91
173
|
if [ "$pass" -eq "$total" ]; then
|
package/operator/lib/deploy.sh
CHANGED
|
@@ -540,9 +540,8 @@ _ground_apply() {
|
|
|
540
540
|
}
|
|
541
541
|
|
|
542
542
|
cmd_deploy() {
|
|
543
|
-
# A LEADING GROUND NAME IS NOT A SUBCOMMAND. `unoverse deploy aws` names the cloud
|
|
544
|
-
#
|
|
545
|
-
# ground off the front when it is there, and leave everything else exactly as it was.
|
|
543
|
+
# A LEADING GROUND NAME IS NOT A SUBCOMMAND. `unoverse deploy aws` names the cloud. Take
|
|
544
|
+
# it off the front when it is there, and leave everything else exactly as it was.
|
|
546
545
|
local GROUND_ARG=""
|
|
547
546
|
case "${1:-}" in
|
|
548
547
|
do|digitalocean|aws|amazon) GROUND_ARG="$1"; shift ;;
|
|
@@ -700,9 +699,8 @@ EOF
|
|
|
700
699
|
# A FIRST DEPLOY TO A NEW SERVER IS A PROVISION, NOT AN IMAGE PULL. Bare `deploy` meant
|
|
701
700
|
# only "pull the latest images and restart", which on a freshly built droplet fails on
|
|
702
701
|
# "Destination directory /opt/gravity does not exist" — /opt/gravity being the directory
|
|
703
|
-
# install.yml creates.
|
|
704
|
-
#
|
|
705
|
-
# journey, so it asks the server what it is and picks the right playbook itself.
|
|
702
|
+
# install.yml creates. Deploy owns the whole journey, so it asks the server what it is and
|
|
703
|
+
# picks the right playbook itself rather than expecting anyone to know a second command.
|
|
706
704
|
# THE MARKER IS "SETUP FINISHED", NOT "A DIRECTORY EXISTS". Testing for /opt/gravity
|
|
707
705
|
# looked right and was not: install.yml creates that directory as its seventh task, so a
|
|
708
706
|
# first-time setup that then FAILED at the database step still left it behind. The next
|
|
@@ -714,7 +712,7 @@ EOF
|
|
|
714
712
|
"$deploy_user@$deploy_host" 'test -f /opt/gravity/.setup-complete' >/dev/null 2>&1; then
|
|
715
713
|
echo ""
|
|
716
714
|
info "This server's setup has not finished. Running it ${DIM}(install, database, verify)${NC}"
|
|
717
|
-
subcommand="
|
|
715
|
+
subcommand="first-time"
|
|
718
716
|
fi
|
|
719
717
|
fi
|
|
720
718
|
|
|
@@ -732,10 +730,10 @@ EOF
|
|
|
732
730
|
-e "universe_root=$ROOT" \
|
|
733
731
|
-e "env_file=$env_prod"
|
|
734
732
|
;;
|
|
735
|
-
|
|
736
|
-
#
|
|
737
|
-
#
|
|
738
|
-
#
|
|
733
|
+
first-time)
|
|
734
|
+
# INTERNAL, never typed. Deploy reaches this by finding no .setup-complete stamp on
|
|
735
|
+
# the server: install → database → verify, end to end. Hardening stays a deliberate
|
|
736
|
+
# follow-up (POCs get verified first; harden when you decide to keep the box).
|
|
739
737
|
info "First-time setup: install → database → verify"
|
|
740
738
|
echo ""
|
|
741
739
|
info "[1/3] Provisioning (Docker, services, mounts)..."
|
|
@@ -743,7 +741,7 @@ EOF
|
|
|
743
741
|
-i "$tmp_inventory" \
|
|
744
742
|
"$ansible_dir/playbooks/install.yml" \
|
|
745
743
|
-e "universe_root=$ROOT" \
|
|
746
|
-
-e "env_file=$env_prod" || { rm -f "$tmp_inventory"; fail "install failed
|
|
744
|
+
-e "env_file=$env_prod" || { rm -f "$tmp_inventory"; fail "install failed. Fix the error above, then: unoverse deploy $cloud"; exit 1; }
|
|
747
745
|
echo ""
|
|
748
746
|
info "[2/3] Database setup..."
|
|
749
747
|
ansible-playbook \
|
|
@@ -751,14 +749,14 @@ EOF
|
|
|
751
749
|
"$ansible_dir/playbooks/db-setup.yml" \
|
|
752
750
|
-e "universe_root=$ROOT" \
|
|
753
751
|
-e "pg_admin_url=$pg_admin_url" \
|
|
754
|
-
-e "env_file=$env_prod" || { rm -f "$tmp_inventory"; fail "
|
|
752
|
+
-e "env_file=$env_prod" || { rm -f "$tmp_inventory"; fail "database setup failed. Fix the error above, then: unoverse deploy $cloud"; exit 1; }
|
|
755
753
|
echo ""
|
|
756
754
|
info "[3/3] Verifying..."
|
|
757
755
|
ansible-playbook \
|
|
758
756
|
-i "$tmp_inventory" \
|
|
759
757
|
"$ansible_dir/playbooks/test-connectivity.yml" \
|
|
760
758
|
-e "universe_root=$ROOT" \
|
|
761
|
-
-e "env_file=$env_prod" || { rm -f "$tmp_inventory"; fail "verification failed
|
|
759
|
+
-e "env_file=$env_prod" || { rm -f "$tmp_inventory"; fail "verification failed. Look at the output above, then: unoverse deploy $cloud"; exit 1; }
|
|
762
760
|
# Only now. Every step passed, so the next deploy can safely be an image push.
|
|
763
761
|
ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -o ConnectTimeout=15 \
|
|
764
762
|
"$deploy_user@$deploy_host" 'touch /opt/gravity/.setup-complete' >/dev/null 2>&1
|
package/operator/lib/ground.sh
CHANGED
|
@@ -239,9 +239,28 @@ _ground_aws() {
|
|
|
239
239
|
region=$(aws configure get region 2>/dev/null)
|
|
240
240
|
[ -n "$region" ] && ok "region: $region" || { region="us-east-1"; info "no default region configured. Using us-east-1"; }
|
|
241
241
|
|
|
242
|
+
# A KEY PAIR YOU CANNOT USE IS NOT A KEY PAIR. This took the FIRST key pair in the region,
|
|
243
|
+
# which on a real account is whatever was created years ago in the console — its private
|
|
244
|
+
# half is not on this laptop, so terraform applied happily and Ansible then died on
|
|
245
|
+
# "Permission denied (publickey)" after eleven minutes of RDS provisioning.
|
|
246
|
+
#
|
|
247
|
+
# The operator's own key is the one that works, because deploying IS ssh-ing from here.
|
|
248
|
+
# Upload it under the universe's name and use that: AWS lets terraform import a public
|
|
249
|
+
# key, so nothing has to pre-exist and nothing has to be downloaded. Fall back to the
|
|
250
|
+
# account's existing pairs only when this machine has no key at all.
|
|
251
|
+
local pubkey=""
|
|
252
|
+
for pubkey in "$HOME/.ssh/id_ed25519.pub" "$HOME/.ssh/id_rsa.pub" ""; do
|
|
253
|
+
[ -n "$pubkey" ] && [ -f "$pubkey" ] && break
|
|
254
|
+
done
|
|
255
|
+
|
|
242
256
|
keys=$(aws ec2 describe-key-pairs --query 'KeyPairs[].KeyName' --output text 2>/dev/null | tr '\t' '\n')
|
|
243
257
|
first_key=$(echo "$keys" | head -1)
|
|
244
|
-
if [ -n "$
|
|
258
|
+
if [ -n "$pubkey" ]; then
|
|
259
|
+
first_key="" # terraform creates the pair from operator_public_key
|
|
260
|
+
ok "SSH key: your own ${DIM}($(basename "$pubkey") — uploaded as ${GROUND_NAME:-this universe}-operator)${NC}"
|
|
261
|
+
elif [ -n "$first_key" ]; then
|
|
262
|
+
warn "no SSH key on this machine — using the account's ${BOLD}$first_key${NC}"
|
|
263
|
+
warn "the deploy will fail unless you hold its private half"
|
|
245
264
|
ok "EC2 key pair: $first_key"
|
|
246
265
|
else
|
|
247
266
|
warn "no EC2 key pairs in $region. Create one first (aws ec2 create-key-pair)"
|
|
@@ -268,7 +287,8 @@ _ground_aws() {
|
|
|
268
287
|
region = "$region"
|
|
269
288
|
name = "$GROUND_NAME"
|
|
270
289
|
admin_cidr = "${ip:-FILL_ME}${ip:+/32}" # YOUR IP — the only SSH source
|
|
271
|
-
ssh_key_name = "${first_key
|
|
290
|
+
ssh_key_name = "${first_key}" # empty = terraform uploads operator_public_key below
|
|
291
|
+
operator_public_key = "$( [ -n "$pubkey" ] && cat "$pubkey" )" # your key: the deploy ssh-es from this machine
|
|
272
292
|
admin_email = "FILL_ME" # initial admin (Cognito user, all roles; invite emailed)
|
|
273
293
|
size = "small" # small (POC) | medium | large
|
|
274
294
|
# Domain is OPTIONAL — empty brings the universe up on the ALB's DNS name over
|
package/package.json
CHANGED