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.
@@ -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 = var.ssh_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
+ }
@@ -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
@@ -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
- # `unoverse deploy init` names the step; `unoverse deploy aws init` does both. Take the
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. The remedy was to know that `unoverse deploy init` exists, which
704
- # is exactly the kind of knowledge this CLI is supposed to remove: deploy owns the whole
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="init"
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
- init|full)
736
- # FIRST-TIME setup, END TO END: install db verify. One command after
737
- # `terraform apply`. Hardening is a deliberate follow-up choice, never a
738
- # default (POCs get verified first; harden when you decide to keep it).
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 fix and re-run: unoverse deploy init"; exit 1; }
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 "db setup failed fix and re-run: unoverse deploy db"; exit 1; }
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 inspect and re-run: unoverse deploy test"; exit 1; }
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
@@ -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 "$first_key" ]; then
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:-FILL_ME}" # must already exist in the region
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unoverse",
3
- "version": "0.1.99",
3
+ "version": "0.1.101",
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",