direxio-deployer 0.1.0

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.
Files changed (77) hide show
  1. package/AGENTS.md +92 -0
  2. package/LICENSE +21 -0
  3. package/README.md +221 -0
  4. package/README_zh.md +218 -0
  5. package/SKILL.md +722 -0
  6. package/agents/README.md +25 -0
  7. package/agents/openai.yaml +12 -0
  8. package/bin/direxio-deployer.mjs +375 -0
  9. package/package.json +28 -0
  10. package/references/agent-targets.md +128 -0
  11. package/references/architecture.md +44 -0
  12. package/references/bug-history.md +78 -0
  13. package/references/deployment-lessons.md +218 -0
  14. package/references/deployment-optimization-audit.md +317 -0
  15. package/references/deployment-workflow.md +341 -0
  16. package/references/iam-policy.json +52 -0
  17. package/references/runtime-wiring.md +209 -0
  18. package/references/state-machine.md +46 -0
  19. package/references/token-refresh.md +81 -0
  20. package/references/tooling.md +106 -0
  21. package/references/troubleshooting.md +26 -0
  22. package/references/user-journey.md +75 -0
  23. package/references/verification-recovery.md +84 -0
  24. package/references/voip-turn-runbook.md +154 -0
  25. package/references/windows-deployment-notes.md +119 -0
  26. package/scripts/aws-credentials.sh +195 -0
  27. package/scripts/cloud-init/Caddyfile +48 -0
  28. package/scripts/cloud-init/docker-compose.yml +125 -0
  29. package/scripts/cloud-init/init-tokens.sh +238 -0
  30. package/scripts/cloud-init/user-data.yaml +40 -0
  31. package/scripts/destroy.ps1 +77 -0
  32. package/scripts/destroy.sh +589 -0
  33. package/scripts/lib/aws.sh +73 -0
  34. package/scripts/lib/domain.sh +175 -0
  35. package/scripts/lib/operation_report.sh +240 -0
  36. package/scripts/lib/ops.sh +230 -0
  37. package/scripts/lib/paths.sh +35 -0
  38. package/scripts/lib/state.sh +137 -0
  39. package/scripts/mcp-tools-list.mjs +95 -0
  40. package/scripts/orchestrate.ps1 +112 -0
  41. package/scripts/orchestrate.sh +1126 -0
  42. package/scripts/phases/s0_prereq_aws.sh +39 -0
  43. package/scripts/phases/s1_preflight.sh +72 -0
  44. package/scripts/phases/s2_domain.sh +103 -0
  45. package/scripts/phases/s3_provision.sh +421 -0
  46. package/scripts/phases/s4_bootstrap_stack.sh +38 -0
  47. package/scripts/phases/s5_init_tokens.sh +118 -0
  48. package/scripts/phases/s6_wire_local.sh +1435 -0
  49. package/scripts/phases/s7_verify_e2e.sh +136 -0
  50. package/scripts/pricing-estimate.sh +256 -0
  51. package/scripts/render/render-userdata.sh +86 -0
  52. package/scripts/reset-app-data.sh +40 -0
  53. package/scripts/update.sh +30 -0
  54. package/tests/aws_credentials_test.sh +139 -0
  55. package/tests/connect_daemon_runtime_check_test.sh +120 -0
  56. package/tests/default_paths_test.sh +58 -0
  57. package/tests/destroy_local_bridge_test.sh +154 -0
  58. package/tests/destroy_root_identity_test.sh +91 -0
  59. package/tests/destroy_route53_zone_test.sh +80 -0
  60. package/tests/domain_authoritative_dns_test.sh +49 -0
  61. package/tests/mcp_doctor_runtime_check_test.sh +86 -0
  62. package/tests/mcp_smoke_runtime_check_test.sh +121 -0
  63. package/tests/mcp_tools_runtime_check_test.sh +123 -0
  64. package/tests/npm_skill_distribution_test.sh +95 -0
  65. package/tests/operation_report_test.sh +258 -0
  66. package/tests/orchestrate_status_recovery_test.sh +91 -0
  67. package/tests/phase_timeout_test.sh +88 -0
  68. package/tests/pricing_estimate_test.sh +159 -0
  69. package/tests/render_userdata_remote_nodes_test.sh +40 -0
  70. package/tests/root_volume_tracking_test.sh +41 -0
  71. package/tests/route53_overwrite_guard_test.sh +86 -0
  72. package/tests/route53_zone_auto_create_test.sh +66 -0
  73. package/tests/runtime_summary_check_test.sh +203 -0
  74. package/tests/s6_wire_local_test.sh +405 -0
  75. package/tests/skill_structure_test.sh +298 -0
  76. package/tests/update_reset_ops_test.sh +230 -0
  77. package/tests/user_confirmation_gates_test.sh +152 -0
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env bash
2
+ # S7 VERIFY_E2E - end-to-end acceptance. DONE only when every check passes.
3
+ #
4
+ # Checks: healthz, Matrix versions, Matrix federation well-known, owner.json+CORS,
5
+ # token-authenticated /_p2p command, and non-empty TURN turnServer.
6
+ # Local bridge message send/read is validated separately; this script checks HTTP actions.
7
+
8
+ run_phase() {
9
+ phase_set S7_VERIFY_E2E in_progress "running end-to-end acceptance"
10
+ local domain token password
11
+ domain=$(state_get domain)
12
+ password=$(state_get password)
13
+ local fails=0
14
+
15
+ _check "healthz" "https://$domain/healthz" "" 200 || fails=$((fails+1))
16
+ _check "matrix versions" "https://$domain/_matrix/client/versions" "" 200 || fails=$((fails+1))
17
+ _check_matrix_server_wellknown "$domain" || fails=$((fails+1))
18
+ _check_owner_cors "$domain" || fails=$((fails+1))
19
+ token=$(_p2p_access_token "$domain" "$password")
20
+ if [ -n "$token" ]; then
21
+ _check_p2p_agent_auth "$domain" "$token" || fails=$((fails+1))
22
+ else
23
+ warn " ✗ _p2p/query mcp.messages.list (failed to exchange fresh access_token)"
24
+ fails=$((fails+1))
25
+ fi
26
+ _check_turn "$domain" "$password" || fails=$((fails+1))
27
+
28
+ if [ "$fails" -eq 0 ]; then
29
+ phase_set S7_VERIFY_E2E done "all green"
30
+ return 0
31
+ fi
32
+ phase_set S7_VERIFY_E2E failed "$fails checks failed"
33
+ warn "$fails acceptance checks failed. See references/troubleshooting.md for targeted fixes."
34
+ return 1
35
+ }
36
+
37
+ _check_p2p_agent_auth() {
38
+ local domain=$1 token=$2 code body
39
+ local room_id
40
+ room_id=$(state_get agent_room_id)
41
+ local args=()
42
+ while IFS= read -r arg; do args+=("$arg"); done < <(curl_resolve_args "$domain")
43
+ body=$(mktemp)
44
+ code=$(curl -sk "${args[@]}" -o "$body" -w '%{http_code}' \
45
+ -X POST "https://$domain/_p2p/query" \
46
+ -H 'Content-Type: application/json' \
47
+ -H "Authorization: Bearer $token" \
48
+ -d "{\"action\":\"mcp.messages.list\",\"params\":{\"room_id\":\"$room_id\",\"limit\":1}}" 2>/dev/null)
49
+ if [ "$code" = "200" ] && jq -e '(.messages | type == "array") and (.room_id | type == "string")' "$body" >/dev/null 2>&1; then
50
+ rm -f "$body"
51
+ ok " ✓ _p2p/query mcp.messages.list (agent token)"
52
+ return 0
53
+ fi
54
+ warn " ✗ _p2p/query mcp.messages.list (got $code, body=$(head -c 120 "$body" 2>/dev/null))"
55
+ rm -f "$body"
56
+ return 1
57
+ }
58
+
59
+ _p2p_access_token() {
60
+ local domain=$1 password=$2 at
61
+ local args=()
62
+ while IFS= read -r arg; do args+=("$arg"); done < <(curl_resolve_args "$domain")
63
+ at=$(curl -sk "${args[@]}" -X POST "https://$domain/_p2p/command" -H 'Content-Type: application/json' \
64
+ -d "{\"action\":\"portal.auth\",\"params\":{\"password\":\"$password\"}}" 2>/dev/null | jq -r '.access_token // empty')
65
+ printf '%s' "$at"
66
+ }
67
+
68
+ curl_resolve_args() {
69
+ local domain=$1 pubip
70
+ pubip=$(res_get public_ip)
71
+ [ -n "$pubip" ] && printf '%s\n' --resolve "$domain:443:$pubip"
72
+ }
73
+
74
+ # Web client reads owner.json from the local dev origin. HTTP 200 without CORS
75
+ # still fails in the browser, so S7 validates the response header.
76
+ _check_owner_cors() {
77
+ local domain=$1 tmp code cors
78
+ tmp=$(mktemp)
79
+ local args=()
80
+ while IFS= read -r arg; do args+=("$arg"); done < <(curl_resolve_args "$domain")
81
+ code=$(curl -sk "${args[@]}" -o /dev/null -D "$tmp" -w '%{http_code}' \
82
+ -H 'Origin: http://127.0.0.1:51820' \
83
+ "https://$domain/.well-known/portal/owner.json" 2>/dev/null)
84
+ cors=$(grep -i '^Access-Control-Allow-Origin:' "$tmp" | tr -d '\r' | head -n 1 || true)
85
+ rm -f "$tmp"
86
+
87
+ if [ "$code" = "200" ] && printf '%s' "$cors" | grep -Eiq 'Access-Control-Allow-Origin:[[:space:]]*(\*|http://127\.0\.0\.1:51820)$'; then
88
+ ok " ✓ portal owner.json (200 + CORS)"; return 0
89
+ fi
90
+ warn " x portal owner.json CORS invalid (code=$code, header=${cors:-<missing>})"; return 1
91
+ }
92
+
93
+ # Federation acceptance: when server_name is a bare domain, remote homeservers
94
+ # default to 8448. This deployment exposes 443, so well-known must point there.
95
+ _check_matrix_server_wellknown() {
96
+ local domain=$1 body
97
+ local args=()
98
+ while IFS= read -r arg; do args+=("$arg"); done < <(curl_resolve_args "$domain")
99
+ body=$(curl -sk "${args[@]}" "https://$domain/.well-known/matrix/server" 2>/dev/null)
100
+ if printf '%s' "$body" | jq -e --arg want "$domain:443" '.["m.server"] == $want' >/dev/null 2>&1; then
101
+ ok " ✓ matrix federation well-known ($domain:443)"; return 0
102
+ fi
103
+ warn " x matrix federation well-known invalid:$(printf '%s' "$body" | head -c 120)"; return 1
104
+ }
105
+
106
+ # _check <name> <url> <bearer-token-or-empty> <expected-code>
107
+ _check() {
108
+ local name=$1 url=$2 tok=$3 want=$4 code
109
+ local domain args=()
110
+ domain=$(state_get domain)
111
+ while IFS= read -r arg; do args+=("$arg"); done < <(curl_resolve_args "$domain")
112
+ if [ -n "$tok" ]; then
113
+ code=$(curl -sk "${args[@]}" -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $tok" "$url" 2>/dev/null)
114
+ else
115
+ code=$(curl -sk "${args[@]}" -o /dev/null -w '%{http_code}' "$url" 2>/dev/null)
116
+ fi
117
+ if [ "$code" = "$want" ]; then ok " ✓ $name ($code)"; return 0
118
+ else warn " ✗ $name (got $code, want $want)"; return 1; fi
119
+ }
120
+
121
+ # TURN acceptance: exchange the backend password/init-code field for Matrix access_token, then verify
122
+ # /voip/turnServer returns non-empty valid TURN credentials.
123
+ _check_turn() {
124
+ local domain=$1 password=$2 at turn
125
+ local args=()
126
+ while IFS= read -r arg; do args+=("$arg"); done < <(curl_resolve_args "$domain")
127
+ at=$(_p2p_access_token "$domain" "$password")
128
+ if [ -z "$at" ]; then warn " x TURN (failed to exchange access_token; cannot verify turnServer)"; return 1; fi
129
+ turn=$(curl -sk "${args[@]}" "https://$domain/_matrix/client/v3/voip/turnServer" \
130
+ -H "Authorization: Bearer $at" 2>/dev/null)
131
+ if printf '%s' "$turn" | jq -e '(.uris|type=="array" and length>0) and (any(.uris[]; test("^turns?:"))) and (.username|tostring|length>0) and (.password|tostring|length>0) and (.ttl>0)' >/dev/null 2>&1; then
132
+ ok " ✓ TURN turnServer non-empty and valid"; return 0
133
+ else
134
+ warn " x TURN turnServer invalid/empty:$(printf '%s' "$turn" | head -c 120)"; return 1
135
+ fi
136
+ }
@@ -0,0 +1,256 @@
1
+ #!/usr/bin/env bash
2
+ # pricing-estimate.sh - estimate monthly AWS costs for a Direxio EC2 node.
3
+ set -euo pipefail
4
+
5
+ usage() {
6
+ cat >&2 <<'EOF'
7
+ Usage:
8
+ scripts/pricing-estimate.sh --state <state.json> [--write-state]
9
+ scripts/pricing-estimate.sh --region <region> --instance-type <type> --disk-gb <gb> --domain-mode <user|route53>
10
+
11
+ Queries AWS Price List where possible. Falls back conservatively when pricing is unavailable.
12
+ EOF
13
+ }
14
+
15
+ region_location() {
16
+ case "$1" in
17
+ us-east-1) echo "US East (N. Virginia)" ;;
18
+ us-east-2) echo "US East (Ohio)" ;;
19
+ us-west-1) echo "US West (N. California)" ;;
20
+ us-west-2) echo "US West (Oregon)" ;;
21
+ ap-southeast-1) echo "Asia Pacific (Singapore)" ;;
22
+ ap-southeast-2) echo "Asia Pacific (Sydney)" ;;
23
+ ap-northeast-1) echo "Asia Pacific (Tokyo)" ;;
24
+ ap-northeast-2) echo "Asia Pacific (Seoul)" ;;
25
+ ap-east-1) echo "Asia Pacific (Hong Kong)" ;;
26
+ eu-west-1) echo "EU (Ireland)" ;;
27
+ eu-central-1) echo "EU (Frankfurt)" ;;
28
+ *) echo "" ;;
29
+ esac
30
+ }
31
+
32
+ price_from_get_products() {
33
+ local service=$1 location=$2 shift_count=2 json
34
+ shift "$shift_count"
35
+ json=$(aws pricing get-products \
36
+ --region us-east-1 \
37
+ --service-code "$service" \
38
+ --filters "Type=TERM_MATCH,Field=location,Value=$location" "$@" \
39
+ --max-results 1 \
40
+ --output json 2>/dev/null) || return 1
41
+ printf '%s\n' "$json" | jq -r '
42
+ .PriceList[0]
43
+ | fromjson
44
+ | .terms.OnDemand
45
+ | to_entries[0].value.priceDimensions
46
+ | to_entries[0].value.pricePerUnit.USD
47
+ ' 2>/dev/null
48
+ }
49
+
50
+ numeric_or_empty() {
51
+ case "${1:-}" in
52
+ ''|null|None) return 1 ;;
53
+ *[!0-9.]* ) return 1 ;;
54
+ *) printf '%s\n' "$1" ;;
55
+ esac
56
+ }
57
+
58
+ lookup_ec2_hourly() {
59
+ local location=$1 instance_type=$2 raw
60
+ raw=$(price_from_get_products AmazonEC2 "$location" \
61
+ "Type=TERM_MATCH,Field=instanceType,Value=$instance_type" \
62
+ "Type=TERM_MATCH,Field=operatingSystem,Value=Linux" \
63
+ "Type=TERM_MATCH,Field=tenancy,Value=Shared" \
64
+ "Type=TERM_MATCH,Field=preInstalledSw,Value=NA" \
65
+ "Type=TERM_MATCH,Field=capacitystatus,Value=Used" \
66
+ ) || return 1
67
+ numeric_or_empty "$raw"
68
+ }
69
+
70
+ lookup_gp3_gb_month() {
71
+ local location=$1 raw
72
+ raw=$(price_from_get_products AmazonEC2 "$location" \
73
+ "Type=TERM_MATCH,Field=productFamily,Value=Storage" \
74
+ "Type=TERM_MATCH,Field=volumeApiName,Value=gp3" \
75
+ ) || return 1
76
+ numeric_or_empty "$raw"
77
+ }
78
+
79
+ lookup_public_ipv4_hourly() {
80
+ local location=$1 raw
81
+ raw=$(price_from_get_products AmazonVPC "$location" \
82
+ "Type=TERM_MATCH,Field=productFamily,Value=Public IPv4 Address" \
83
+ ) || return 1
84
+ numeric_or_empty "$raw"
85
+ }
86
+
87
+ round2() {
88
+ awk -v n="${1:-0}" 'BEGIN { printf "%.2f", n + 0 }'
89
+ }
90
+
91
+ build_estimate() {
92
+ local region=$1 instance_type=$2 disk_gb=$3 domain_mode=$4
93
+ local hours=730 location ec2_hourly gp3_rate public_ipv4_hourly route53_monthly status warnings_json
94
+ local ec2_source gp3_source ipv4_source
95
+ location=$(region_location "$region")
96
+ status=queried
97
+ warnings_json='[]'
98
+
99
+ if [ -z "$location" ]; then
100
+ status=fallback
101
+ warnings_json=$(jq -cn --arg w "Region is not mapped to an AWS Pricing location; using conservative fallback estimates" '$ARGS.named | [.w]')
102
+ fi
103
+
104
+ if [ "$status" = "queried" ] && ec2_hourly=$(lookup_ec2_hourly "$location" "$instance_type"); then
105
+ ec2_source=aws_pricing
106
+ else
107
+ status=fallback
108
+ ec2_hourly=${DIREXIO_FALLBACK_EC2_HOURLY_USD:-0.030}
109
+ ec2_source=fallback
110
+ fi
111
+
112
+ if [ "$status" = "queried" ] && gp3_rate=$(lookup_gp3_gb_month "$location"); then
113
+ gp3_source=aws_pricing
114
+ else
115
+ status=fallback
116
+ gp3_rate=${DIREXIO_FALLBACK_GP3_GB_MONTH_USD:-0.10}
117
+ gp3_source=fallback
118
+ fi
119
+
120
+ if [ "$status" = "queried" ] && public_ipv4_hourly=$(lookup_public_ipv4_hourly "$location"); then
121
+ ipv4_source=aws_pricing
122
+ else
123
+ [ "$status" = "fallback" ] || status=fallback
124
+ public_ipv4_hourly=${DIREXIO_FALLBACK_PUBLIC_IPV4_HOURLY_USD:-0.005}
125
+ ipv4_source=fallback
126
+ fi
127
+
128
+ if [ "$status" = "fallback" ]; then
129
+ warnings_json=$(printf '%s\n' "$warnings_json" | jq '. + ["AWS Pricing API unavailable; using conservative fallback estimates"] | unique')
130
+ fi
131
+
132
+ if [ "$domain_mode" = "route53" ]; then
133
+ route53_monthly=${DIREXIO_ROUTE53_HOSTED_ZONE_MONTHLY_USD:-0.50}
134
+ else
135
+ route53_monthly=0
136
+ fi
137
+
138
+ jq -n \
139
+ --arg pricing_status "$status" \
140
+ --arg region "$region" \
141
+ --arg location "$location" \
142
+ --arg instance_type "$instance_type" \
143
+ --arg domain_mode "$domain_mode" \
144
+ --arg ec2_source "$ec2_source" \
145
+ --arg gp3_source "$gp3_source" \
146
+ --arg ipv4_source "$ipv4_source" \
147
+ --argjson warnings "$warnings_json" \
148
+ --argjson hours "$hours" \
149
+ --argjson disk_gb "$disk_gb" \
150
+ --argjson ec2_hourly "$ec2_hourly" \
151
+ --argjson ec2_monthly "$(round2 "$(awk -v h="$ec2_hourly" -v m="$hours" 'BEGIN { print h*m }')")" \
152
+ --argjson gp3_rate "$gp3_rate" \
153
+ --argjson gp3_monthly "$(round2 "$(awk -v r="$gp3_rate" -v gb="$disk_gb" 'BEGIN { print r*gb }')")" \
154
+ --argjson ipv4_hourly "$public_ipv4_hourly" \
155
+ --argjson ipv4_monthly "$(round2 "$(awk -v h="$public_ipv4_hourly" -v m="$hours" 'BEGIN { print h*m }')")" \
156
+ --argjson route53_monthly "$route53_monthly" '
157
+ def total:
158
+ (.components.ec2_instance.monthly_usd
159
+ + .components.ebs_gp3.monthly_usd
160
+ + .components.public_ipv4.monthly_usd
161
+ + .components.route53_hosted_zone.monthly_usd);
162
+ {
163
+ pricing_status: $pricing_status,
164
+ region: $region,
165
+ location: $location,
166
+ hours_per_month: $hours,
167
+ warnings: $warnings,
168
+ components: {
169
+ ec2_instance: {
170
+ instance_type: $instance_type,
171
+ hourly_usd: $ec2_hourly,
172
+ monthly_usd: $ec2_monthly,
173
+ source: $ec2_source
174
+ },
175
+ ebs_gp3: {
176
+ storage_gb: $disk_gb,
177
+ gb_month_usd: $gp3_rate,
178
+ monthly_usd: $gp3_monthly,
179
+ source: $gp3_source
180
+ },
181
+ public_ipv4: {
182
+ hourly_usd: $ipv4_hourly,
183
+ monthly_usd: $ipv4_monthly,
184
+ billed_even_when_attached: true,
185
+ source: $ipv4_source
186
+ },
187
+ route53_hosted_zone: {
188
+ monthly_usd: $route53_monthly,
189
+ included: ($domain_mode == "route53")
190
+ }
191
+ },
192
+ notes: [
193
+ "Estimate excludes data transfer, TURN relay traffic, domain registration, taxes, and AWS credit eligibility.",
194
+ "Public IPv4 is billed hourly by AWS even when attached to a running instance.",
195
+ "AWS credits may reduce charges only when the account, plan, region, and service usage are eligible; verify in AWS Billing Console."
196
+ ],
197
+ recommendations: [
198
+ "Set an AWS Budget or billing alert before leaving the node running.",
199
+ "Review AWS Billing Console after deployment and after destroy to confirm actual charges and remaining credits."
200
+ ]
201
+ } | .total_monthly_usd = ((total * 100 | round) / 100)
202
+ '
203
+ }
204
+
205
+ state=""
206
+ write_state=0
207
+ region=""
208
+ instance_type=""
209
+ disk_gb=""
210
+ domain_mode=""
211
+
212
+ while [ "$#" -gt 0 ]; do
213
+ case "$1" in
214
+ --state) state=${2:-}; shift 2 ;;
215
+ --write-state) write_state=1; shift ;;
216
+ --region) region=${2:-}; shift 2 ;;
217
+ --instance-type) instance_type=${2:-}; shift 2 ;;
218
+ --disk-gb) disk_gb=${2:-}; shift 2 ;;
219
+ --domain-mode) domain_mode=${2:-}; shift 2 ;;
220
+ -h|--help) usage; exit 0 ;;
221
+ *) usage; exit 1 ;;
222
+ esac
223
+ done
224
+
225
+ if [ -n "$state" ]; then
226
+ [ -f "$state" ] || {
227
+ echo "state.json not found: $state" >&2
228
+ exit 1
229
+ }
230
+ region=${region:-$(jq -r '.region // empty' "$state")}
231
+ instance_type=${instance_type:-$(jq -r '.instance_type // empty' "$state")}
232
+ domain_mode=${domain_mode:-$(jq -r '.domain_mode // "user"' "$state")}
233
+ disk_gb=${disk_gb:-$(jq -r '.resources.root_volume_gb // .root_volume_gb // "8"' "$state")}
234
+ fi
235
+
236
+ region=${region:-${AWS_DEFAULT_REGION:-${AWS_REGION:-}}}
237
+ instance_type=${instance_type:-t3.small}
238
+ disk_gb=${disk_gb:-8}
239
+ domain_mode=${domain_mode:-user}
240
+
241
+ [ -n "$region" ] || {
242
+ echo "region is required for pricing estimate" >&2
243
+ exit 1
244
+ }
245
+
246
+ estimate=$(build_estimate "$region" "$instance_type" "$disk_gb" "$domain_mode")
247
+ if [ "$write_state" = "1" ]; then
248
+ [ -n "$state" ] || {
249
+ echo "--write-state requires --state" >&2
250
+ exit 1
251
+ }
252
+ tmp="$state.tmp.$$"
253
+ jq --argjson estimate "$estimate" '.cost_estimate = $estimate' "$state" > "$tmp" && mv "$tmp" "$state"
254
+ fi
255
+
256
+ printf '%s\n' "$estimate"
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env bash
2
+ # render-userdata.sh - render final cloud-init user-data.
3
+ #
4
+ # Bundle cloud-init deployment files (docker-compose.yml / Caddyfile /
5
+ # init-tokens.sh) into a tar.gz, inline it as one write_files entry, and unpack
6
+ # it to /opt/p2p in runcmd. Comment-only lines are stripped at the end to keep
7
+ # AWS user-data below the 16384-byte limit. Replaces __DOMAIN__ /
8
+ # __ACME_EMAIL__ / __MESSAGE_SERVER_IMAGE__; the EC2 instance does not need to
9
+ # clone repos.
10
+ #
11
+ # Usage:
12
+ # render-userdata.sh --domain <domain> --acme <email> --message-server-image <img> > user-data.yaml
13
+ set -euo pipefail
14
+
15
+ HERE=$(cd "$(dirname "$0")/.." && pwd)
16
+ CI="$HERE/cloud-init"
17
+ source "$HERE/lib/domain.sh"
18
+
19
+ DOMAIN=""; ACME=""; MESSAGE_SERVER_IMAGE=""
20
+ while [ $# -gt 0 ]; do
21
+ case "$1" in
22
+ --domain) DOMAIN=$2; shift 2;;
23
+ --acme) ACME=$2; shift 2;;
24
+ --message-server-image) MESSAGE_SERVER_IMAGE=$2; shift 2;;
25
+ --as-image) MESSAGE_SERVER_IMAGE=$2; shift 2;;
26
+ *) echo "unknown arg: $1" >&2; exit 1;;
27
+ esac
28
+ done
29
+ [ -n "$MESSAGE_SERVER_IMAGE" ] || { echo "--message-server-image required" >&2; exit 1; }
30
+ [ -n "$DOMAIN" ] || { echo "--domain required; production deployments require a real domain" >&2; exit 1; }
31
+ DOMAIN=$(domain_normalize "$DOMAIN")
32
+ [ "$DOMAIN" != "PLACEHOLDER" ] || { echo "PLACEHOLDER/sslip.io domains are not accepted in the production renderer" >&2; exit 1; }
33
+ domain_is_formal_name "$DOMAIN" || { echo "invalid production domain: $DOMAIN" >&2; exit 1; }
34
+
35
+ # Single-line base64 compatible with GNU/Linux and macOS/BSD base64.
36
+ b64() { base64 | tr -d '\n'; }
37
+ sed_replacement_escape() { printf '%s' "$1" | sed 's/[\\&#]/\\&/g'; }
38
+
39
+ # Build a deterministic tar.gz bundle with fixed permissions and no extra attrs.
40
+ WORK=$(mktemp -d)
41
+ trap 'rm -rf "$WORK"' EXIT
42
+ cp "$CI/docker-compose.yml" "$WORK/docker-compose.yml"
43
+ cp "$CI/Caddyfile" "$WORK/Caddyfile"
44
+ tr -d '\r' < "$CI/init-tokens.sh" > "$WORK/init-tokens.sh"
45
+ chmod 0644 "$WORK/docker-compose.yml" "$WORK/Caddyfile"
46
+ chmod 0755 "$WORK/init-tokens.sh"
47
+ find "$WORK" -name '._*' -delete
48
+ # -C creates a flat archive. Explicit gzip avoids macOS tar stdout quirks.
49
+ # COPYFILE_DISABLE=1 avoids AppleDouble ._* extended-attribute files.
50
+ BUNDLE_B64=$(COPYFILE_DISABLE=1 tar -C "$WORK" -cf - docker-compose.yml Caddyfile init-tokens.sh | gzip -n | b64)
51
+
52
+ # Generate user-data: append the bundle entry to write_files and unpack first in runcmd.
53
+ # Avoid passing multiline strings via awk -v; macOS awk rejects newline in string.
54
+ EXTRA_WF=$(mktemp); trap 'rm -rf "$WORK" "$EXTRA_WF"' EXIT
55
+ cat > "$EXTRA_WF" <<EOF
56
+ - path: /opt/p2p/bundle.tar.gz
57
+ permissions: '0644'
58
+ encoding: b64
59
+ content: $BUNDLE_B64
60
+ EOF
61
+
62
+ # Insert unpack as the first runcmd step before Docker install / compose up.
63
+ UNPACK=' - mkdir -p /opt/p2p && tar -xzf /opt/p2p/bundle.tar.gz -C /opt/p2p && chmod 0755 /opt/p2p/init-tokens.sh'
64
+
65
+ strip_userdata_comments() {
66
+ awk '
67
+ NR == 1 && $0 == "#cloud-config" { print; next }
68
+ /^[[:space:]]*#/ { next }
69
+ { print }
70
+ '
71
+ }
72
+
73
+ awk -v wf="$EXTRA_WF" -v unpack="$UNPACK" '
74
+ # Insert bundle entry before runcmd.
75
+ /^runcmd:/ && !wfdone {
76
+ while ((getline line < wf) > 0) print line
77
+ close(wf)
78
+ print
79
+ print unpack
80
+ wfdone=1
81
+ next
82
+ }
83
+ { print }
84
+ ' "$CI/user-data.yaml" \
85
+ | sed "s#__DOMAIN__#$(sed_replacement_escape "$DOMAIN")#g; s#__ACME_EMAIL__#$(sed_replacement_escape "$ACME")#g; s#__MESSAGE_SERVER_IMAGE__#$(sed_replacement_escape "$MESSAGE_SERVER_IMAGE")#g" \
86
+ | strip_userdata_comments
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env bash
2
+ # reset-app-data.sh - clear app data on an existing node while preserving infra/TLS.
3
+ set -euo pipefail
4
+
5
+ HERE=$(cd "$(dirname "$0")" && pwd)
6
+ # shellcheck disable=SC1090
7
+ source "$HERE/lib/paths.sh"
8
+ # shellcheck disable=SC1090
9
+ source "$HERE/lib/operation_report.sh"
10
+ # shellcheck disable=SC1090
11
+ source "$HERE/lib/ops.sh"
12
+
13
+ STATE_JSON=$(ops_state_path "${1:-}")
14
+ ops_require_state "$STATE_JSON"
15
+
16
+ if [ "${DIREXIO_RESET_APP_DATA_CONFIRM:-0}" != "1" ]; then
17
+ cat >&2 <<'EOF'
18
+ reset-app-data is destructive for application data.
19
+ It preserves EC2, Elastic IP/public IPv4, DNS, and Caddy TLS volumes, but clears Matrix/message-server data.
20
+ Set DIREXIO_RESET_APP_DATA_CONFIRM=1 to continue.
21
+ EOF
22
+ exit 2
23
+ fi
24
+
25
+ remote_command=$(ops_reset_remote_command)
26
+ ops_ssh "$STATE_JSON" "$remote_command"
27
+ ops_mark_refresh_pending "$STATE_JSON" S4_BOOTSTRAP_STACK
28
+ if ops_stop_scoped_daemon "$STATE_JSON"; then
29
+ bridge_stop_message="Scoped local bridge daemon was stopped; rerun S6 to install fresh config."
30
+ else
31
+ bridge_stop_message="Scoped local bridge daemon stop was skipped or not needed."
32
+ fi
33
+ report=$(ops_write_report reset_app_data reset_remote_data_cleared_refresh_pending "$STATE_JSON")
34
+
35
+ echo "Application data reset complete on the existing node."
36
+ echo "Caddy TLS storage was preserved."
37
+ echo "Old user confirmations and runtime checks were cleared."
38
+ echo "$bridge_stop_message"
39
+ echo "Local S4-S7 gates were reset; rerun orchestrate with P2P_EXISTING_STATE_ACTION=continue."
40
+ echo "operation report: $report"
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env bash
2
+ # update.sh - update an existing EC2 node without recreating infra or deleting data.
3
+ set -euo pipefail
4
+
5
+ HERE=$(cd "$(dirname "$0")" && pwd)
6
+ # shellcheck disable=SC1090
7
+ source "$HERE/lib/paths.sh"
8
+ # shellcheck disable=SC1090
9
+ source "$HERE/lib/operation_report.sh"
10
+ # shellcheck disable=SC1090
11
+ source "$HERE/lib/ops.sh"
12
+
13
+ STATE_JSON=$(ops_state_path "${1:-}")
14
+ ops_require_state "$STATE_JSON"
15
+
16
+ remote_command=$(ops_update_remote_command "${MESSAGE_SERVER_IMAGE:-}")
17
+ ops_ssh "$STATE_JSON" "$remote_command"
18
+ ops_mark_refresh_pending "$STATE_JSON" S4_BOOTSTRAP_STACK
19
+ if ops_stop_scoped_daemon "$STATE_JSON"; then
20
+ bridge_stop_message="Scoped local bridge daemon was stopped; rerun S6 to install fresh config."
21
+ else
22
+ bridge_stop_message="Scoped local bridge daemon stop was skipped or not needed."
23
+ fi
24
+ report=$(ops_write_report update update_remote_restart_complete_refresh_pending "$STATE_JSON")
25
+
26
+ echo "Update remote restart complete."
27
+ echo "Old user confirmations and runtime checks were cleared."
28
+ echo "$bridge_stop_message"
29
+ echo "Local S4-S7 gates were reset; rerun orchestrate with P2P_EXISTING_STATE_ACTION=continue to refresh credentials, MCP, and verification."
30
+ echo "operation report: $report"