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,77 @@
1
+ param(
2
+ [Parameter(ValueFromRemainingArguments = $true)]
3
+ [string[]] $DestroyArgs
4
+ )
5
+
6
+ $ErrorActionPreference = 'Stop'
7
+
8
+ $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
9
+ $RepoRoot = Split-Path -Parent $ScriptDir
10
+
11
+ function Find-GitBash {
12
+ $candidates = @(
13
+ "$env:ProgramFiles\Git\bin\bash.exe",
14
+ "$env:ProgramFiles\Git\usr\bin\bash.exe",
15
+ "$env:LOCALAPPDATA\Programs\Git\bin\bash.exe",
16
+ "$env:LOCALAPPDATA\Programs\Git\usr\bin\bash.exe"
17
+ )
18
+ foreach ($candidate in $candidates) {
19
+ if ($candidate -and (Test-Path $candidate)) {
20
+ return $candidate
21
+ }
22
+ }
23
+ throw "Git Bash was not found. Install Git for Windows or run scripts/destroy.sh from a POSIX shell."
24
+ }
25
+
26
+ function ConvertTo-GitBashPath([string] $Path) {
27
+ $normalized = $Path.Replace('\', '/')
28
+ if ($normalized -match '^/[A-Za-z]/') {
29
+ return $normalized
30
+ }
31
+ if ($normalized -match '^/mnt/[A-Za-z]/') {
32
+ $drive = $normalized.Substring(5, 1).ToLowerInvariant()
33
+ $rest = $normalized.Substring(6)
34
+ return "/$drive$rest"
35
+ }
36
+ $full = [System.IO.Path]::GetFullPath($Path)
37
+ $drive = $full.Substring(0, 1).ToLowerInvariant()
38
+ $rest = $full.Substring(2).Replace('\', '/')
39
+ return "/$drive$rest"
40
+ }
41
+
42
+ function Convert-ArgumentForGitBash([string] $Value) {
43
+ if ($Value -match '^[A-Za-z]:[\\/]') {
44
+ return ConvertTo-GitBashPath $Value
45
+ }
46
+ if ($Value -match '^\.{1,2}[\\/]') {
47
+ return ConvertTo-GitBashPath (Join-Path (Get-Location).ProviderPath $Value)
48
+ }
49
+ return $Value
50
+ }
51
+
52
+ function Quote-BashArg([string] $Value) {
53
+ return "'" + ($Value -replace "'", "'\''") + "'"
54
+ }
55
+
56
+ $bash = Find-GitBash
57
+
58
+ $windowsDirexioHome = if ($env:DIREXIO_HOME -and ($env:DIREXIO_HOME -notmatch '^/[A-Za-z]/' -and $env:DIREXIO_HOME -notmatch '^/mnt/[A-Za-z]/')) {
59
+ $env:DIREXIO_HOME
60
+ } else {
61
+ Join-Path $env:USERPROFILE '.direxio'
62
+ }
63
+ $env:DIREXIO_WINDOWS_HOME = $windowsDirexioHome
64
+ $env:DIREXIO_HOME = ConvertTo-GitBashPath $windowsDirexioHome
65
+ $env:DIREXIO_LOCAL_PATH_STYLE = 'windows'
66
+
67
+ if ($env:P2P_WORKDIR) {
68
+ $env:P2P_WORKDIR_WINDOWS = $env:P2P_WORKDIR
69
+ $env:P2P_WORKDIR = ConvertTo-GitBashPath $env:P2P_WORKDIR
70
+ }
71
+
72
+ $repoRootForBash = ConvertTo-GitBashPath $RepoRoot
73
+ $quotedArgs = ($DestroyArgs | ForEach-Object { Quote-BashArg (Convert-ArgumentForGitBash $_) }) -join ' '
74
+ $command = "cd $(Quote-BashArg $repoRootForBash) && ./scripts/destroy.sh $quotedArgs"
75
+
76
+ & $bash -lc $command
77
+ exit $LASTEXITCODE
@@ -0,0 +1,589 @@
1
+ #!/usr/bin/env bash
2
+ # destroy.sh - remove AWS resources recorded by deployment state.
3
+ #
4
+ # Source:
5
+ # 1. $P2P_WORKDIR/state.json written by orchestrate.sh; by default
6
+ # DOMAIN=__DOMAIN__ maps to ~/.direxio/nodes/<service_id>/state.json.
7
+ # 2. explicit argument: bash destroy.sh /path/to/state.json
8
+ #
9
+ # Order: terminate instance -> release EIP -> delete security group -> delete key pair
10
+ # -> remove the corresponding local service directory.
11
+ # Each cloud step is tolerant of already-removed resources.
12
+ set -uo pipefail
13
+
14
+ HERE=$(cd "$(dirname "$0")" && pwd)
15
+ # shellcheck disable=SC1090
16
+ source "$HERE/lib/paths.sh"
17
+ # shellcheck disable=SC1090
18
+ source "$HERE/lib/aws.sh"
19
+ # shellcheck disable=SC1090
20
+ source "$HERE/lib/operation_report.sh"
21
+ P2P_WORKDIR=$(direxio_default_workdir)
22
+
23
+ log() { echo -e "\033[33m[destroy]\033[0m $*"; }
24
+
25
+ destroy_now() {
26
+ date -u +%Y-%m-%dT%H:%M:%SZ
27
+ }
28
+
29
+ destroy_evidence_set() {
30
+ local key=$1 status=$2 detail=${3:-} tmp
31
+ tmp="$SRC.tmp.destroy.$$"
32
+ if jq --arg key "$key" \
33
+ --arg status "$status" \
34
+ --arg detail "$detail" \
35
+ --arg checked_at "$(destroy_now)" \
36
+ '.destroy_evidence[$key] = {
37
+ status: $status,
38
+ detail: $detail,
39
+ checked_at: $checked_at
40
+ }' "$SRC" > "$tmp"; then
41
+ mv "$tmp" "$SRC"
42
+ else
43
+ rm -f "$tmp"
44
+ log " (failed to record destroy evidence for $key)"
45
+ fi
46
+ }
47
+
48
+ route53_a_record_present() {
49
+ local zone_id=$1 domain=$2 public_ip=$3 rrsets present
50
+ rrsets=$(aws route53 list-resource-record-sets --hosted-zone-id "$zone_id" --output json 2>/dev/null) || return 2
51
+ present=$(printf '%s\n' "$rrsets" | jq -r --arg name "$domain." --arg ip "$public_ip" '
52
+ any(.ResourceRecordSets[]?;
53
+ .Name == $name
54
+ and .Type == "A"
55
+ and any(.ResourceRecords[]?; .Value == $ip)
56
+ )
57
+ ' 2>/dev/null) || return 2
58
+ [ "$present" = "true" ]
59
+ }
60
+
61
+ verify_route53_a_record_deleted() {
62
+ local zone_id=$1 domain=$2 public_ip=$3 rc
63
+ if [ -z "$zone_id" ] || [ -z "$domain" ] || [ -z "$public_ip" ]; then
64
+ destroy_evidence_set route53_a_record skipped "missing hosted zone, domain, or public IP"
65
+ return 0
66
+ fi
67
+ route53_a_record_present "$zone_id" "$domain" "$public_ip"
68
+ rc=$?
69
+ case "$rc" in
70
+ 0) destroy_evidence_set route53_a_record still_present "$domain still resolves to recorded IP $public_ip in hosted zone $zone_id" ;;
71
+ 1) destroy_evidence_set route53_a_record deleted "$domain A $public_ip is absent from hosted zone $zone_id" ;;
72
+ *) destroy_evidence_set route53_a_record unknown "could not verify hosted zone $zone_id record state" ;;
73
+ esac
74
+ }
75
+
76
+ verify_route53_hosted_zone_deleted() {
77
+ local zone_id=$1 out
78
+ if [ -z "$zone_id" ]; then
79
+ destroy_evidence_set route53_hosted_zone skipped "missing hosted zone id"
80
+ return 0
81
+ fi
82
+ if out=$(aws route53 get-hosted-zone --id "$zone_id" --output json 2>/dev/null); then
83
+ if [ -n "$out" ]; then
84
+ destroy_evidence_set route53_hosted_zone still_present "hosted zone $zone_id still exists"
85
+ else
86
+ destroy_evidence_set route53_hosted_zone unknown "empty get-hosted-zone response for $zone_id"
87
+ fi
88
+ else
89
+ destroy_evidence_set route53_hosted_zone deleted "hosted zone $zone_id is absent"
90
+ fi
91
+ }
92
+
93
+ verify_ec2_instance_terminated() {
94
+ local instance_id=$1 state
95
+ if [ -z "$instance_id" ]; then
96
+ destroy_evidence_set ec2_instance skipped "missing instance id"
97
+ return 0
98
+ fi
99
+ if state=$(aws ec2 describe-instances --instance-ids "$instance_id" \
100
+ --query 'Reservations[0].Instances[0].State.Name' --output text 2>/dev/null); then
101
+ case "$state" in
102
+ terminated) destroy_evidence_set ec2_instance terminated "instance $instance_id state is terminated" ;;
103
+ ""|"None") destroy_evidence_set ec2_instance unknown "instance $instance_id returned no state" ;;
104
+ *) destroy_evidence_set ec2_instance "$state" "instance $instance_id state is $state" ;;
105
+ esac
106
+ else
107
+ destroy_evidence_set ec2_instance not_found "instance $instance_id was not returned by EC2"
108
+ fi
109
+ }
110
+
111
+ verify_ebs_root_volume_deleted() {
112
+ local volume_id=$1 state
113
+ if [ -z "$volume_id" ]; then
114
+ destroy_evidence_set ebs_root_volume skipped "missing root volume id"
115
+ return 0
116
+ fi
117
+ if state=$(aws ec2 describe-volumes --volume-ids "$volume_id" \
118
+ --query 'Volumes[0].State' --output text 2>/dev/null); then
119
+ case "$state" in
120
+ ""|"None") destroy_evidence_set ebs_root_volume deleted "root volume $volume_id is absent" ;;
121
+ deleted) destroy_evidence_set ebs_root_volume deleted "root volume $volume_id state is deleted" ;;
122
+ *) destroy_evidence_set ebs_root_volume "$state" "root volume $volume_id state is $state" ;;
123
+ esac
124
+ else
125
+ destroy_evidence_set ebs_root_volume deleted "root volume $volume_id is absent"
126
+ fi
127
+ }
128
+
129
+ verify_elastic_ip_released() {
130
+ local allocation_id=$1 out
131
+ if [ -z "$allocation_id" ]; then
132
+ destroy_evidence_set elastic_ip skipped "missing allocation id"
133
+ return 0
134
+ fi
135
+ if out=$(aws ec2 describe-addresses --allocation-ids "$allocation_id" \
136
+ --query 'Addresses[0].AllocationId' --output text 2>/dev/null); then
137
+ case "$out" in
138
+ ""|"None") destroy_evidence_set elastic_ip released "allocation $allocation_id is absent" ;;
139
+ *) destroy_evidence_set elastic_ip still_allocated "allocation $allocation_id still exists" ;;
140
+ esac
141
+ else
142
+ destroy_evidence_set elastic_ip released "allocation $allocation_id is absent"
143
+ fi
144
+ }
145
+
146
+ verify_security_group_deleted() {
147
+ local group_id=$1 out
148
+ if [ -z "$group_id" ]; then
149
+ destroy_evidence_set security_group skipped "missing security group id"
150
+ return 0
151
+ fi
152
+ if out=$(aws ec2 describe-security-groups --group-ids "$group_id" \
153
+ --query 'SecurityGroups[0].GroupId' --output text 2>/dev/null); then
154
+ case "$out" in
155
+ ""|"None") destroy_evidence_set security_group deleted "security group $group_id is absent" ;;
156
+ *) destroy_evidence_set security_group still_present "security group $group_id still exists" ;;
157
+ esac
158
+ else
159
+ destroy_evidence_set security_group deleted "security group $group_id is absent"
160
+ fi
161
+ }
162
+
163
+ verify_key_pair_deleted() {
164
+ local key_name=$1 out
165
+ if [ -z "$key_name" ]; then
166
+ destroy_evidence_set key_pair skipped "missing key pair name"
167
+ return 0
168
+ fi
169
+ if out=$(aws ec2 describe-key-pairs --key-names "$key_name" \
170
+ --query 'KeyPairs[0].KeyName' --output text 2>/dev/null); then
171
+ case "$out" in
172
+ ""|"None") destroy_evidence_set key_pair deleted "key pair $key_name is absent" ;;
173
+ *) destroy_evidence_set key_pair still_present "key pair $key_name still exists" ;;
174
+ esac
175
+ else
176
+ destroy_evidence_set key_pair deleted "key pair $key_name is absent"
177
+ fi
178
+ }
179
+
180
+ # Resolve source and load INSTANCE_ID/EIP_ID/SG_ID/KEY_NAME/KEY_FILE/REGION.
181
+ SRC=${1:-}
182
+ if [ -z "$SRC" ]; then
183
+ if [ -f "$P2P_WORKDIR/state.json" ]; then SRC="$P2P_WORKDIR/state.json"
184
+ else echo "state.json not found; set DOMAIN=<service domain> or P2P_WORKDIR=<service dir> to destroy a specific deployment."; exit 1
185
+ fi
186
+ fi
187
+ [ -f "$SRC" ] || { echo "$SRC not found."; exit 1; }
188
+ P2P_ROOT=$(cd "${DIREXIO_HOME:-$HOME/.direxio}" 2>/dev/null && pwd -P || printf '%s' "${DIREXIO_HOME:-$HOME/.direxio}")
189
+
190
+ command -v jq >/dev/null 2>&1 || { echo "jq is required to parse state.json."; exit 1; }
191
+ REGION=$(jq -r '.region // empty' "$SRC")
192
+ INSTANCE_ID=$(jq -r '.resources.instance_id // empty' "$SRC")
193
+ ROOT_VOLUME_ID=$(jq -r '.resources.root_volume_id // empty' "$SRC")
194
+ EIP_ID=$(jq -r '.resources.eip_id // empty' "$SRC")
195
+ SG_ID=$(jq -r '.resources.sg_id // empty' "$SRC")
196
+ KEY_NAME=$(jq -r '.resources.key_name // empty' "$SRC")
197
+ KEY_FILE=$(jq -r '.resources.key_file // empty' "$SRC")
198
+ DOMAIN_MODE=$(jq -r '.domain_mode // empty' "$SRC")
199
+ DOMAIN=$(jq -r '.domain // empty' "$SRC")
200
+ AS_URL=$(jq -r '.as_url // empty' "$SRC")
201
+ PUBLIC_IP=$(jq -r '.resources.public_ip // empty' "$SRC")
202
+ ROUTE53_ZONE_ID=$(jq -r '.resources.route53_zone_id // empty' "$SRC")
203
+ ROUTE53_ZONE_NAME=$(jq -r '.resources.route53_zone_name // empty' "$SRC")
204
+ ROUTE53_ZONE_CREATED_BY_DEPLOYER=$(jq -r '.resources.route53_zone_created_by_deployer // empty' "$SRC")
205
+ CC_CONNECT_CONFIG=$(jq -r '.cc_connect_config // empty' "$SRC")
206
+ CC_CONNECT_BINARY=$(jq -r '.cc_connect_binary // empty' "$SRC")
207
+ CC_CONNECT_RUNTIME_DIR=$(jq -r '.cc_connect_runtime_dir // empty' "$SRC")
208
+ AGENT_SERVICE_DIR=$(jq -r '.agent_service_dir // empty' "$SRC")
209
+ AGENT_SERVICE_ID=$(jq -r '.agent_service_id // empty' "$SRC")
210
+
211
+ export NO_PROXY="*"; export no_proxy="*"
212
+ unset HTTP_PROXY HTTPS_PROXY http_proxy https_proxy 2>/dev/null || true
213
+ [ -n "${REGION:-${AWS_DEFAULT_REGION:-}}" ] || {
214
+ echo "Region is missing. Add .region to state.json or set AWS_DEFAULT_REGION, then retry."
215
+ exit 1
216
+ }
217
+ export AWS_DEFAULT_REGION=${REGION:-${AWS_DEFAULT_REGION:-}}
218
+
219
+ log "source = $SRC (region=$AWS_DEFAULT_REGION)"
220
+
221
+ AWS_IDENTITY_ARN=$(aws_identity_arn)
222
+ if [ -z "$AWS_IDENTITY_ARN" ] || [ "$AWS_IDENTITY_ARN" = "None" ]; then
223
+ echo "AWS credentials are required before destroy can remove cloud resources or local state."
224
+ exit 1
225
+ fi
226
+
227
+ find_route53_zone() {
228
+ local domain=$1 best_id="" best_name="" best_len=0 id name clean len
229
+ while IFS=$'\t' read -r id name; do
230
+ id=${id%$'\r'}
231
+ name=${name%$'\r'}
232
+ clean=${name%.}
233
+ case "$domain" in
234
+ "$clean"|*."$clean")
235
+ len=${#clean}
236
+ if [ "$len" -gt "$best_len" ]; then
237
+ best_id=${id#/hostedzone/}
238
+ best_name=$clean
239
+ best_len=$len
240
+ fi
241
+ ;;
242
+ esac
243
+ done < <(aws route53 list-hosted-zones --output json 2>/dev/null | jq -r '.HostedZones[] | [.Id, .Name] | @tsv')
244
+ [ -n "$best_id" ] && printf '%s\t%s\n' "$best_id" "$best_name"
245
+ }
246
+
247
+ delete_route53_record() {
248
+ local domain=$1 public_ip=$2 zone zone_id zone_name change_file change_json change_id
249
+ [ -n "$domain" ] && [ -n "$public_ip" ] || return 0
250
+ zone_id=${ROUTE53_ZONE_ID:-}
251
+ zone_name=${ROUTE53_ZONE_NAME:-}
252
+ if [ -z "$zone_id" ]; then
253
+ zone=$(find_route53_zone "$domain")
254
+ zone_id=$(printf '%s' "$zone" | cut -f1)
255
+ zone_name=$(printf '%s' "$zone" | cut -f2)
256
+ fi
257
+ if [ -z "$zone_id" ]; then
258
+ log "Route53 hosted zone not found for $domain; leaving DNS record untouched"
259
+ destroy_evidence_set route53_a_record skipped "hosted zone not found for $domain"
260
+ return 0
261
+ fi
262
+
263
+ log "deleting Route53 A record $domain -> $public_ip (zone=$zone_name) ..."
264
+ change_file=$(mktemp)
265
+ cat > "$change_file" <<EOF
266
+ {
267
+ "Comment": "p2p-matrix destroy",
268
+ "Changes": [
269
+ {
270
+ "Action": "DELETE",
271
+ "ResourceRecordSet": {
272
+ "Name": "$domain.",
273
+ "Type": "A",
274
+ "TTL": 60,
275
+ "ResourceRecords": [{ "Value": "$public_ip" }]
276
+ }
277
+ }
278
+ ]
279
+ }
280
+ EOF
281
+ local change_file_aws="$change_file"
282
+ if command -v cygpath >/dev/null 2>&1; then
283
+ change_file_aws=$(cygpath -w "$change_file")
284
+ fi
285
+ change_json=$(aws route53 change-resource-record-sets \
286
+ --hosted-zone-id "$zone_id" \
287
+ --change-batch "file://$change_file_aws" \
288
+ --output json 2>/dev/null) \
289
+ || log " (Route53 A record may already be absent or changed; check DNS manually)"
290
+ rm -f "$change_file"
291
+ if [ -n "${change_json:-}" ]; then
292
+ change_id=$(printf '%s\n' "$change_json" | jq -r '.ChangeInfo.Id // empty' 2>/dev/null)
293
+ [ -n "$change_id" ] && aws route53 wait resource-record-sets-changed --id "$change_id" 2>/dev/null || true
294
+ fi
295
+ verify_route53_a_record_deleted "$zone_id" "$domain" "$public_ip"
296
+ }
297
+
298
+ delete_route53_hosted_zone_if_owned() {
299
+ local zone_id=${ROUTE53_ZONE_ID:-}
300
+ [ -n "$zone_id" ] || return 0
301
+ if [ "${ROUTE53_ZONE_CREATED_BY_DEPLOYER:-}" != "true" ]; then
302
+ log "Route53 hosted zone $zone_id was not created by this deployer run; leaving it in place"
303
+ destroy_evidence_set route53_hosted_zone retained "hosted zone $zone_id was not created by this deployer run"
304
+ return 0
305
+ fi
306
+ log "deleting deployer-created Route53 hosted zone $zone_id ..."
307
+ aws route53 delete-hosted-zone --id "$zone_id" >/dev/null 2>&1 \
308
+ || log " (hosted zone was not deleted; it may contain records, be already absent, or require manual review)"
309
+ verify_route53_hosted_zone_deleted "$zone_id"
310
+ }
311
+
312
+ normalize_local_path() {
313
+ local path=$1 drive rest
314
+ path=$(printf '%s' "$path" | sed 's#\\#/#g')
315
+ if command -v cygpath >/dev/null 2>&1; then
316
+ cygpath -m "$path" 2>/dev/null && return 0
317
+ fi
318
+ case "$path" in
319
+ /mnt/[A-Za-z]/*)
320
+ drive=${path#/mnt/}
321
+ drive=${drive%%/*}
322
+ rest=${path#/mnt/$drive/}
323
+ printf '%s:/%s\n' "$(printf '%s' "$drive" | tr '[:lower:]' '[:upper:]')" "$rest"
324
+ return 0
325
+ ;;
326
+ /cygdrive/[A-Za-z]/*)
327
+ drive=${path#/cygdrive/}
328
+ drive=${drive%%/*}
329
+ rest=${path#/cygdrive/$drive/}
330
+ printf '%s:/%s\n' "$(printf '%s' "$drive" | tr '[:lower:]' '[:upper:]')" "$rest"
331
+ return 0
332
+ ;;
333
+ /[A-Za-z]/*)
334
+ drive=${path#/}
335
+ drive=${drive%%/*}
336
+ rest=${path#/$drive/}
337
+ printf '%s:/%s\n' "$(printf '%s' "$drive" | tr '[:lower:]' '[:upper:]')" "$rest"
338
+ return 0
339
+ ;;
340
+ esac
341
+ while [ "${#path}" -gt 1 ] && [ "${path%/}" != "$path" ]; do
342
+ case "$path" in [A-Za-z]:/) break ;; esac
343
+ path=${path%/}
344
+ done
345
+ printf '%s\n' "$path"
346
+ }
347
+
348
+ local_dirname() {
349
+ local path
350
+ path=$(normalize_local_path "$1")
351
+ case "$path" in
352
+ */*) printf '%s\n' "${path%/*}" ;;
353
+ *) printf '.\n' ;;
354
+ esac
355
+ }
356
+
357
+ paths_equal() {
358
+ local left right
359
+ left=$(normalize_local_path "$1")
360
+ right=$(normalize_local_path "$2")
361
+ case "$left:$right" in
362
+ [A-Za-z]:/*:[A-Za-z]:/*)
363
+ [ "$(printf '%s' "$left" | tr '[:upper:]' '[:lower:]')" = "$(printf '%s' "$right" | tr '[:upper:]' '[:lower:]')" ]
364
+ ;;
365
+ *)
366
+ [ "$left" = "$right" ]
367
+ ;;
368
+ esac
369
+ }
370
+
371
+ current_service_dir() {
372
+ local recorded=$1 asurl=$2 domain=$3 config=${4:-}
373
+ if [ -n "$recorded" ]; then
374
+ printf '%s\n' "$recorded"
375
+ return 0
376
+ fi
377
+ if [ -n "$asurl" ] || [ -n "$domain" ]; then
378
+ direxio_service_dir "${asurl:-$domain}"
379
+ return 0
380
+ fi
381
+ if [ -n "$config" ]; then
382
+ local_dirname "$(local_dirname "$config")"
383
+ fi
384
+ }
385
+
386
+ cc_connect_stop_binary() {
387
+ local binary=$1 runtime_dir=$2 candidate
388
+ if [ -n "$runtime_dir" ]; then
389
+ candidate="$runtime_dir/bin/direxio-connect"
390
+ if [ -x "$candidate" ]; then
391
+ printf '%s\n' "$candidate"
392
+ return 0
393
+ fi
394
+ candidate="$runtime_dir/bin/direxio-connect.exe"
395
+ if [ -x "$candidate" ]; then
396
+ printf '%s\n' "$candidate"
397
+ return 0
398
+ fi
399
+ fi
400
+ if [ -n "$binary" ]; then
401
+ printf '%s\n' "$binary"
402
+ return 0
403
+ fi
404
+ printf 'direxio-connect\n'
405
+ }
406
+
407
+ cc_connect_target_work_dir() {
408
+ local config=$1 runtime_dir=$2 service_dir=$3
409
+ if [ -n "$config" ]; then
410
+ local_dirname "$config"
411
+ return 0
412
+ fi
413
+ if [ -n "$runtime_dir" ]; then
414
+ normalize_local_path "$runtime_dir"
415
+ return 0
416
+ fi
417
+ if [ -n "$service_dir" ]; then
418
+ normalize_local_path "$service_dir/cc-connect"
419
+ fi
420
+ }
421
+
422
+ cc_connect_service_name() {
423
+ local service_id=$1 service_dir=$2 asurl=$3 domain=$4
424
+ if [ -n "$service_id" ]; then
425
+ printf '%s\n' "$service_id"
426
+ return 0
427
+ fi
428
+ if [ -n "$service_dir" ]; then
429
+ basename "$service_dir"
430
+ return 0
431
+ fi
432
+ if [ -n "$asurl" ] || [ -n "$domain" ]; then
433
+ direxio_service_id "${asurl:-$domain}"
434
+ return 0
435
+ fi
436
+ printf 'cc-connect\n'
437
+ }
438
+
439
+ cc_connect_status_work_dir() {
440
+ local binary=$1 service_name=$2 out
441
+ out=$("$binary" daemon status --service-name "$service_name" 2>/dev/null) || return 1
442
+ printf '%s\n' "$out" | sed -nE 's/^[[:space:]]*WorkDir:[[:space:]]*//p' | head -n 1
443
+ }
444
+
445
+ stop_current_cc_connect_daemon() {
446
+ local config=$1 binary=$2 runtime_dir=$3 service_dir=$4 service_name=$5 target_work_dir running_work_dir stop_binary
447
+ target_work_dir=$(cc_connect_target_work_dir "$config" "$runtime_dir" "$service_dir")
448
+ if [ -z "$target_work_dir" ]; then
449
+ log "cc-connect service directory not recorded; skipping local daemon stop"
450
+ return 0
451
+ fi
452
+
453
+ stop_binary=$(cc_connect_stop_binary "$binary" "$runtime_dir")
454
+ case "$stop_binary" in
455
+ */*|[A-Za-z]:/*|[A-Za-z]:\\*) ;;
456
+ *)
457
+ if ! command -v "$stop_binary" >/dev/null 2>&1; then
458
+ log "cc-connect binary not found on PATH; skipping local daemon stop"
459
+ return 0
460
+ fi
461
+ ;;
462
+ esac
463
+
464
+ running_work_dir=$(cc_connect_status_work_dir "$stop_binary" "$service_name")
465
+ if [ -z "$running_work_dir" ]; then
466
+ log "cc-connect daemon status has no WorkDir; skipping local daemon stop"
467
+ return 0
468
+ fi
469
+
470
+ if ! paths_equal "$target_work_dir" "$running_work_dir"; then
471
+ log "cc-connect daemon belongs to another service; leaving daemon running"
472
+ return 0
473
+ fi
474
+
475
+ log "stopping cc-connect daemon for current service ..."
476
+ if "$stop_binary" daemon stop --service-name "$service_name" >/dev/null 2>&1; then
477
+ log "cc-connect daemon stopped"
478
+ else
479
+ log "cc-connect daemon stop failed or service was not installed; continuing destroy"
480
+ fi
481
+ log "uninstalling cc-connect daemon for current service ..."
482
+ if "$stop_binary" daemon uninstall --service-name "$service_name" >/dev/null 2>&1; then
483
+ log "cc-connect daemon uninstalled"
484
+ else
485
+ log "cc-connect daemon uninstall failed or service was not installed; continuing destroy"
486
+ fi
487
+ }
488
+
489
+ cleanup_local_service_dir() {
490
+ local service_dir=$1 root=$2 nodes_root src_real nodes_real src_norm nodes_norm name
491
+
492
+ if [ "${P2P_KEEP_WORKDIR:-0}" = "1" ]; then
493
+ log "keeping local service dir because P2P_KEEP_WORKDIR=1: $service_dir"
494
+ return 0
495
+ fi
496
+
497
+ [ -n "$service_dir" ] && [ -d "$service_dir" ] || return 0
498
+ [ -n "$root" ] || return 0
499
+
500
+ nodes_root="$root/nodes"
501
+ [ -d "$nodes_root" ] || {
502
+ log "local service root not found; leaving $service_dir untouched"
503
+ return 0
504
+ }
505
+ src_real=$(cd "$service_dir" 2>/dev/null && pwd -P) || return 0
506
+ nodes_real=$(cd "$nodes_root" 2>/dev/null && pwd -P) || return 0
507
+ src_norm=$(normalize_local_path "$src_real")
508
+ nodes_norm=$(normalize_local_path "$nodes_real")
509
+ case "$src_norm" in
510
+ "$nodes_norm"/*) ;;
511
+ *)
512
+ log "refusing to remove local service dir outside $nodes_norm: $service_dir"
513
+ return 0
514
+ ;;
515
+ esac
516
+
517
+ name=$(basename "$src_norm")
518
+ case "$name" in
519
+ ""|"."|".."|"nodes"|"cc-connect")
520
+ log "refusing to remove unexpected local service dir: $service_dir"
521
+ return 0
522
+ ;;
523
+ esac
524
+
525
+ log "removing local service dir $src_real ..."
526
+ rm -rf -- "$src_real"
527
+ }
528
+
529
+ # 0. Remove DNS record if ops created it through Route53 mode.
530
+ CURRENT_SERVICE_DIR=$(current_service_dir "$AGENT_SERVICE_DIR" "$AS_URL" "$DOMAIN" "$CC_CONNECT_CONFIG")
531
+ CURRENT_SERVICE_NAME=$(cc_connect_service_name "$AGENT_SERVICE_ID" "$CURRENT_SERVICE_DIR" "$AS_URL" "$DOMAIN")
532
+ stop_current_cc_connect_daemon "$CC_CONNECT_CONFIG" "$CC_CONNECT_BINARY" "$CC_CONNECT_RUNTIME_DIR" "$CURRENT_SERVICE_DIR" "$CURRENT_SERVICE_NAME"
533
+
534
+ if [ "${DOMAIN_MODE:-}" = "route53" ]; then
535
+ delete_route53_record "$DOMAIN" "$PUBLIC_IP"
536
+ delete_route53_hosted_zone_if_owned
537
+ fi
538
+
539
+ # 1. Terminate instance.
540
+ if [ -n "${INSTANCE_ID:-}" ]; then
541
+ log "terminating instance $INSTANCE_ID ..."
542
+ aws ec2 terminate-instances --instance-ids "$INSTANCE_ID" >/dev/null 2>&1 || log " (instance may already be gone)"
543
+ aws ec2 wait instance-terminated --instance-ids "$INSTANCE_ID" 2>/dev/null || true
544
+ verify_ec2_instance_terminated "$INSTANCE_ID"
545
+ else
546
+ verify_ec2_instance_terminated ""
547
+ fi
548
+ verify_ebs_root_volume_deleted "${ROOT_VOLUME_ID:-}"
549
+
550
+ # 2. Release Elastic IP.
551
+ if [ -n "${EIP_ID:-}" ]; then
552
+ log "releasing Elastic IP $EIP_ID ..."
553
+ aws ec2 release-address --allocation-id "$EIP_ID" 2>/dev/null || log " (EIP may already be released)"
554
+ verify_elastic_ip_released "$EIP_ID"
555
+ else
556
+ verify_elastic_ip_released ""
557
+ fi
558
+
559
+ # 3. Delete security group after instance/network interfaces detach.
560
+ if [ -n "${SG_ID:-}" ]; then
561
+ log "deleting security group $SG_ID ..."
562
+ for i in 1 2 3 4 5; do
563
+ if aws ec2 delete-security-group --group-id "$SG_ID" 2>/dev/null; then break; fi
564
+ sleep 6
565
+ [ "$i" = 5 ] && log " (security group delete failed; an ENI may still be attached, delete it manually later)"
566
+ done
567
+ verify_security_group_deleted "$SG_ID"
568
+ else
569
+ verify_security_group_deleted ""
570
+ fi
571
+
572
+ # 4. Delete key pair and local private key.
573
+ if [ -n "${KEY_NAME:-}" ]; then
574
+ log "deleting key pair $KEY_NAME ..."
575
+ aws ec2 delete-key-pair --key-name "$KEY_NAME" 2>/dev/null || true
576
+ [ -n "${KEY_FILE:-}" ] && [ -f "$KEY_FILE" ] && rm -f "$KEY_FILE"
577
+ verify_key_pair_deleted "$KEY_NAME"
578
+ else
579
+ verify_key_pair_deleted ""
580
+ fi
581
+
582
+ log "Done. Processed resources recorded in $SRC."
583
+ log "User-managed DNS and domain purchases are outside automatic destroy scope; handle them manually if needed."
584
+ if REPORT_PATH=$(operation_report_write destroy destroy_processed "$SRC" 2>/dev/null); then
585
+ log "operation report written: $REPORT_PATH"
586
+ else
587
+ log "operation report was not written; keep destroy logs for audit"
588
+ fi
589
+ cleanup_local_service_dir "$CURRENT_SERVICE_DIR" "$P2P_ROOT"