tenseleyflow/shithub / b105298

Browse files

deploy/doctl: add actions runner pool bootstrap

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
b1052980bb246fb9919fec55f014a08d8b16c4a0
Parents
9d05372
Tree
8ae1ecf

4 changed files

StatusFile+-
A deploy/doctl/README.md 60 0
A deploy/doctl/actions-runner-cloud-init.yaml 23 0
A deploy/doctl/generate-actions-runner-inventory.sh 155 0
A deploy/doctl/provision-actions-runner-pool.sh 346 0
deploy/doctl/README.mdadded
@@ -0,0 +1,60 @@
1
+# DigitalOcean runner pool helpers
2
+
3
+These scripts are the S41j operator path for creating shithub Actions runner
4
+hosts without using the DigitalOcean dashboard.
5
+
6
+They create only infrastructure. They do not generate, store, or print runner
7
+registration tokens.
8
+
9
+## Provision a pool
10
+
11
+```sh
12
+SSH_KEY_NAME=macbook-pro \
13
+SSH_ALLOWED_CIDRS=203.0.113.4/32 \
14
+./deploy/doctl/provision-actions-runner-pool.sh --dry-run
15
+
16
+SSH_KEY_NAME=macbook-pro \
17
+SSH_ALLOWED_CIDRS=203.0.113.4/32 \
18
+./deploy/doctl/provision-actions-runner-pool.sh
19
+```
20
+
21
+Defaults:
22
+
23
+- pool: `shared-linux`
24
+- project: `shithub-prod`
25
+- region: `sfo3`
26
+- size: `s-2vcpu-4gb`
27
+- image: `ubuntu-24-04-x64`
28
+- tag: `shithub-actions-runner`
29
+- cloud-init: `deploy/doctl/actions-runner-cloud-init.yaml`
30
+
31
+`SSH_ALLOWED_CIDRS` must be one or more operator/VPN CIDRs. The provisioner
32
+refuses `0.0.0.0/0` and `::/0` for SSH.
33
+
34
+## Generate inventory
35
+
36
+```sh
37
+./deploy/doctl/generate-actions-runner-inventory.sh \
38
+  --output deploy/ansible/inventory/actions-runners
39
+```
40
+
41
+Replace the generated token placeholders with per-host values from
42
+`shithubd admin runner register`, preferably through ansible-vault or host_vars.
43
+Then run:
44
+
45
+```sh
46
+make build
47
+cd deploy/ansible
48
+ansible-playbook -i inventory/actions-runners site.yml -t shithubd-runner
49
+```
50
+
51
+## Destroy a test pool
52
+
53
+List runner droplets:
54
+
55
+```sh
56
+doctl compute droplet list --tag-name shithub-actions-runner
57
+```
58
+
59
+Delete specific test droplets by ID or name only after draining/revoking the
60
+runner in shithub.
deploy/doctl/actions-runner-cloud-init.yamladded
@@ -0,0 +1,23 @@
1
+#cloud-config
2
+#
3
+# SPDX-License-Identifier: AGPL-3.0-or-later
4
+#
5
+# First-boot baseline for DigitalOcean Actions runner droplets.
6
+# Do not put runner tokens, database URLs, or other secrets here; this file is
7
+# visible in Droplet metadata. Registration tokens are written later by Ansible.
8
+
9
+package_update: true
10
+packages:
11
+  - ca-certificates
12
+  - curl
13
+  - docker.io
14
+  - git
15
+  - ipset
16
+  - iptables
17
+  - jq
18
+  - python3
19
+  - rsync
20
+
21
+runcmd:
22
+  - [systemctl, enable, --now, docker]
23
+  - [systemctl, status, docker, --no-pager]
deploy/doctl/generate-actions-runner-inventory.shadded
@@ -0,0 +1,155 @@
1
+#!/usr/bin/env bash
2
+# SPDX-License-Identifier: AGPL-3.0-or-later
3
+#
4
+# Generate an Ansible inventory for DigitalOcean shithub Actions runners.
5
+
6
+set -euo pipefail
7
+
8
+POOL_NAME="${POOL_NAME:-shared-linux}"
9
+RESOURCE_TAG="${RESOURCE_TAG:-shithub-actions-runner}"
10
+NAME_PREFIX="${NAME_PREFIX:-}"
11
+OUTPUT="${OUTPUT:-}"
12
+ANSIBLE_USER="${ANSIBLE_USER:-root}"
13
+SERVER_URL="${SHITHUB_RUNNER_SERVER_URL:-https://shithub.sh}"
14
+LABELS="${SHITHUB_RUNNER_LABELS:-self-hosted,linux,ubuntu-latest,x64}"
15
+CAPACITY="${SHITHUB_RUNNER_CAPACITY:-1}"
16
+DEFAULT_IMAGE="${SHITHUB_RUNNER_DEFAULT_IMAGE:-ghcr.io/shithub/runner-nix:1.0}"
17
+TOKEN_PLACEHOLDER="${SHITHUB_RUNNER_TOKEN_PLACEHOLDER:-REPLACE_WITH_RUNNER_TOKEN}"
18
+
19
+usage() {
20
+	cat <<'USAGE'
21
+Usage:
22
+  deploy/doctl/generate-actions-runner-inventory.sh [flags]
23
+
24
+Flags:
25
+  --pool-name NAME       Pool slug used in droplet names (default: shared-linux)
26
+  --resource-tag TAG     DigitalOcean tag to read (default: shithub-actions-runner)
27
+  --name-prefix PREFIX   Droplet name prefix (default: shithub-runner-<pool-name>-)
28
+  --output PATH          Write inventory to PATH instead of stdout
29
+  --ansible-user USER    SSH user for Ansible (default: root)
30
+  --server-url URL       shithub server URL (default: https://shithub.sh)
31
+  --labels LIST          Runner labels (default: self-hosted,linux,ubuntu-latest,x64)
32
+  --capacity N           Runner capacity per host (default: 1)
33
+  --default-image IMAGE  Runner default image
34
+  --token-placeholder S  Placeholder text for per-host runner tokens
35
+  -h, --help             Show this help
36
+USAGE
37
+}
38
+
39
+fatal() {
40
+	echo "fatal: $*" >&2
41
+	exit 2
42
+}
43
+
44
+while [[ $# -gt 0 ]]; do
45
+	case "$1" in
46
+	--pool-name)
47
+		POOL_NAME="${2:?missing value for --pool-name}"
48
+		shift 2
49
+		;;
50
+	--resource-tag)
51
+		RESOURCE_TAG="${2:?missing value for --resource-tag}"
52
+		shift 2
53
+		;;
54
+	--name-prefix)
55
+		NAME_PREFIX="${2:?missing value for --name-prefix}"
56
+		shift 2
57
+		;;
58
+	--output)
59
+		OUTPUT="${2:?missing value for --output}"
60
+		shift 2
61
+		;;
62
+	--ansible-user)
63
+		ANSIBLE_USER="${2:?missing value for --ansible-user}"
64
+		shift 2
65
+		;;
66
+	--server-url)
67
+		SERVER_URL="${2:?missing value for --server-url}"
68
+		shift 2
69
+		;;
70
+	--labels)
71
+		LABELS="${2:?missing value for --labels}"
72
+		shift 2
73
+		;;
74
+	--capacity)
75
+		CAPACITY="${2:?missing value for --capacity}"
76
+		shift 2
77
+		;;
78
+	--default-image)
79
+		DEFAULT_IMAGE="${2:?missing value for --default-image}"
80
+		shift 2
81
+		;;
82
+	--token-placeholder)
83
+		TOKEN_PLACEHOLDER="${2:?missing value for --token-placeholder}"
84
+		shift 2
85
+		;;
86
+	-h | --help)
87
+		usage
88
+		exit 0
89
+		;;
90
+	*)
91
+		fatal "unknown flag: $1"
92
+		;;
93
+	esac
94
+done
95
+
96
+if [[ -z "$NAME_PREFIX" ]]; then
97
+	NAME_PREFIX="shithub-runner-$POOL_NAME-"
98
+fi
99
+
100
+command -v doctl >/dev/null 2>&1 || fatal "doctl not on PATH"
101
+command -v jq >/dev/null 2>&1 || fatal "jq not on PATH"
102
+
103
+if ! doctl account get >/dev/null 2>&1; then
104
+	fatal "doctl is not authenticated; run 'doctl auth init'"
105
+fi
106
+
107
+droplets_json="$(doctl compute droplet list --tag-name "$RESOURCE_TAG" --output json |
108
+	jq --arg prefix "$NAME_PREFIX" '[.[] | select(.name | startswith($prefix)) | {
109
+		id: (.id | tostring),
110
+		name: .name,
111
+		status: .status,
112
+		public_ipv4: ((.networks.v4 // []) | map(select(.type == "public")) | first | .ip_address // ""),
113
+		private_ipv4: ((.networks.v4 // []) | map(select(.type == "private")) | first | .ip_address // "")
114
+	}] | sort_by(.name)')"
115
+
116
+count="$(jq 'length' <<<"$droplets_json")"
117
+(( count > 0 )) || fatal "no droplets tagged $RESOURCE_TAG with prefix $NAME_PREFIX"
118
+public_count="$(jq '[.[] | select(.public_ipv4 != "")] | length' <<<"$droplets_json")"
119
+(( public_count > 0 )) || fatal "no matching droplets have public IPv4 addresses for direct Ansible SSH"
120
+
121
+render_inventory() {
122
+	cat <<HEADER
123
+# Generated by deploy/doctl/generate-actions-runner-inventory.sh.
124
+# Store the real shithub_runner_token values in ansible-vault or host_vars.
125
+
126
+[actions_runners]
127
+HEADER
128
+	jq -r \
129
+		--arg user "$ANSIBLE_USER" \
130
+		--arg token "$TOKEN_PLACEHOLDER" \
131
+		'.[] | select(.public_ipv4 != "") |
132
+		"\(.name) ansible_host=\(.public_ipv4) ansible_user=\($user) do_droplet_id=\(.id) do_private_ipv4=\(.private_ipv4) shithub_runner_token=\($token)_\(.name | ascii_upcase | gsub("[^A-Z0-9]"; "_"))"' \
133
+		<<<"$droplets_json"
134
+
135
+	cat <<VARS
136
+
137
+[actions_runners:vars]
138
+shithub_runner_enabled=true
139
+shithub_runner_server_url=$SERVER_URL
140
+shithub_runner_engine=docker
141
+shithub_runner_labels=$LABELS
142
+shithub_runner_capacity=$CAPACITY
143
+shithub_runner_default_image=$DEFAULT_IMAGE
144
+VARS
145
+}
146
+
147
+if [[ -n "$OUTPUT" ]]; then
148
+	mkdir -p "$(dirname "$OUTPUT")"
149
+	tmp="$OUTPUT.tmp"
150
+	render_inventory >"$tmp"
151
+	mv "$tmp" "$OUTPUT"
152
+	echo "wrote $OUTPUT" >&2
153
+else
154
+	render_inventory
155
+fi
deploy/doctl/provision-actions-runner-pool.shadded
@@ -0,0 +1,346 @@
1
+#!/usr/bin/env bash
2
+# SPDX-License-Identifier: AGPL-3.0-or-later
3
+#
4
+# Provision a small DigitalOcean-backed shithub Actions runner pool.
5
+#
6
+# This intentionally creates cattle runner hosts only. It does not register
7
+# runner tokens and does not write any shithub production secrets. Registration
8
+# tokens are generated with shithubd and distributed by Ansible after droplets
9
+# exist.
10
+
11
+set -euo pipefail
12
+
13
+SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
14
+
15
+POOL_NAME="${POOL_NAME:-shared-linux}"
16
+PROJECT_NAME="${PROJECT_NAME:-shithub-prod}"
17
+REGION="${REGION:-sfo3}"
18
+SIZE="${SIZE:-s-2vcpu-4gb}"
19
+IMAGE="${IMAGE:-ubuntu-24-04-x64}"
20
+COUNT="${COUNT:-1}"
21
+SSH_KEY_NAME="${SSH_KEY_NAME:-}"
22
+SSH_ALLOWED_CIDRS="${SSH_ALLOWED_CIDRS:-}"
23
+VPC_UUID="${VPC_UUID:-}"
24
+RESOURCE_TAG="${RESOURCE_TAG:-shithub-actions-runner}"
25
+POOL_TAG="${POOL_TAG:-}"
26
+FIREWALL_NAME="${FIREWALL_NAME:-}"
27
+USER_DATA_FILE="${USER_DATA_FILE:-$SCRIPT_DIR/actions-runner-cloud-init.yaml}"
28
+DRY_RUN=0
29
+
30
+usage() {
31
+	cat <<'USAGE'
32
+Usage:
33
+  deploy/doctl/provision-actions-runner-pool.sh [flags]
34
+
35
+Flags:
36
+  --pool-name NAME          Pool slug used in droplet names (default: shared-linux)
37
+  --project-name NAME       DigitalOcean project name (default: shithub-prod)
38
+  --region SLUG             Droplet region (default: sfo3)
39
+  --size SLUG               Droplet size (default: s-2vcpu-4gb)
40
+  --image SLUG              Droplet image (default: ubuntu-24-04-x64)
41
+  --count N                 Desired droplet count for this pool (default: 1)
42
+  --ssh-key-name NAME       DigitalOcean SSH key name to install for root
43
+  --ssh-allowed-cidrs LIST  Comma-separated CIDRs allowed to SSH to runners
44
+  --vpc-uuid UUID           Optional VPC UUID for the droplets
45
+  --resource-tag TAG        Shared tag for all runner droplets (default: shithub-actions-runner)
46
+  --pool-tag TAG            Extra pool tag (default: shithub-actions-<pool-name>)
47
+  --firewall-name NAME      Cloud firewall name (default: shithub-actions-runners-<pool-name>)
48
+  --user-data-file PATH     Cloud-init file with no secrets
49
+  --dry-run                 Validate inputs and print the plan without creating resources
50
+  -h, --help                Show this help
51
+
52
+Environment variables with the same uppercase names are also honored.
53
+
54
+Example:
55
+  SSH_KEY_NAME=macbook-pro \
56
+  SSH_ALLOWED_CIDRS=203.0.113.4/32 \
57
+  ./deploy/doctl/provision-actions-runner-pool.sh --dry-run
58
+USAGE
59
+}
60
+
61
+fatal() {
62
+	echo "fatal: $*" >&2
63
+	exit 2
64
+}
65
+
66
+log() {
67
+	echo "$*" >&2
68
+}
69
+
70
+trim() {
71
+	local s="$1"
72
+	s="${s#"${s%%[![:space:]]*}"}"
73
+	s="${s%"${s##*[![:space:]]}"}"
74
+	printf '%s' "$s"
75
+}
76
+
77
+require_tool() {
78
+	command -v "$1" >/dev/null 2>&1 || fatal "$1 not on PATH"
79
+}
80
+
81
+while [[ $# -gt 0 ]]; do
82
+	case "$1" in
83
+	--pool-name)
84
+		POOL_NAME="${2:?missing value for --pool-name}"
85
+		shift 2
86
+		;;
87
+	--project-name)
88
+		PROJECT_NAME="${2:?missing value for --project-name}"
89
+		shift 2
90
+		;;
91
+	--region)
92
+		REGION="${2:?missing value for --region}"
93
+		shift 2
94
+		;;
95
+	--size)
96
+		SIZE="${2:?missing value for --size}"
97
+		shift 2
98
+		;;
99
+	--image)
100
+		IMAGE="${2:?missing value for --image}"
101
+		shift 2
102
+		;;
103
+	--count)
104
+		COUNT="${2:?missing value for --count}"
105
+		shift 2
106
+		;;
107
+	--ssh-key-name)
108
+		SSH_KEY_NAME="${2:?missing value for --ssh-key-name}"
109
+		shift 2
110
+		;;
111
+	--ssh-allowed-cidrs)
112
+		SSH_ALLOWED_CIDRS="${2:?missing value for --ssh-allowed-cidrs}"
113
+		shift 2
114
+		;;
115
+	--vpc-uuid)
116
+		VPC_UUID="${2:?missing value for --vpc-uuid}"
117
+		shift 2
118
+		;;
119
+	--resource-tag)
120
+		RESOURCE_TAG="${2:?missing value for --resource-tag}"
121
+		shift 2
122
+		;;
123
+	--pool-tag)
124
+		POOL_TAG="${2:?missing value for --pool-tag}"
125
+		shift 2
126
+		;;
127
+	--firewall-name)
128
+		FIREWALL_NAME="${2:?missing value for --firewall-name}"
129
+		shift 2
130
+		;;
131
+	--user-data-file)
132
+		USER_DATA_FILE="${2:?missing value for --user-data-file}"
133
+		shift 2
134
+		;;
135
+	--dry-run)
136
+		DRY_RUN=1
137
+		shift
138
+		;;
139
+	-h | --help)
140
+		usage
141
+		exit 0
142
+		;;
143
+	*)
144
+		fatal "unknown flag: $1"
145
+		;;
146
+	esac
147
+done
148
+
149
+[[ "$POOL_NAME" =~ ^[a-z0-9][a-z0-9-]*$ ]] || fatal "pool name must be a lowercase slug"
150
+[[ "$RESOURCE_TAG" =~ ^[A-Za-z0-9:_.-]+$ ]] || fatal "resource tag contains unsupported characters"
151
+[[ "$COUNT" =~ ^[0-9]+$ ]] || fatal "count must be a positive integer"
152
+(( COUNT > 0 )) || fatal "count must be greater than zero"
153
+[[ -n "$REGION" ]] || fatal "region is required"
154
+[[ -n "$SIZE" ]] || fatal "size is required"
155
+[[ -n "$IMAGE" ]] || fatal "image is required"
156
+[[ -n "$SSH_KEY_NAME" ]] || fatal "set --ssh-key-name or SSH_KEY_NAME"
157
+[[ -n "$SSH_ALLOWED_CIDRS" ]] || fatal "set --ssh-allowed-cidrs or SSH_ALLOWED_CIDRS"
158
+[[ -r "$USER_DATA_FILE" ]] || fatal "user-data file not readable: $USER_DATA_FILE"
159
+
160
+if [[ -z "$POOL_TAG" ]]; then
161
+	POOL_TAG="shithub-actions-$POOL_NAME"
162
+fi
163
+if [[ -z "$FIREWALL_NAME" ]]; then
164
+	FIREWALL_NAME="shithub-actions-runners-$POOL_NAME"
165
+fi
166
+
167
+SSH_RULES=()
168
+IFS=',' read -r -a CIDR_PARTS <<<"$SSH_ALLOWED_CIDRS"
169
+for raw in "${CIDR_PARTS[@]}"; do
170
+	cidr="$(trim "$raw")"
171
+	[[ -n "$cidr" ]] || continue
172
+	case "$cidr" in
173
+	0.0.0.0/0 | ::/0 | 0/0)
174
+		fatal "refusing public SSH CIDR $cidr; use your operator/VPN IP range"
175
+		;;
176
+	esac
177
+	[[ "$cidr" == */* ]] || fatal "SSH CIDR must include a prefix length: $cidr"
178
+	SSH_RULES+=("protocol:tcp,ports:22,address:$cidr")
179
+done
180
+(( ${#SSH_RULES[@]} > 0 )) || fatal "at least one non-public SSH CIDR is required"
181
+SSH_INBOUND_RULES="${SSH_RULES[*]}"
182
+OUTBOUND_RULES="protocol:tcp,ports:all,address:0.0.0.0/0 protocol:udp,ports:all,address:0.0.0.0/0 protocol:icmp,ports:all,address:0.0.0.0/0"
183
+
184
+require_tool doctl
185
+require_tool jq
186
+
187
+if ! doctl auth list >/dev/null 2>&1 || ! doctl account get >/dev/null 2>&1; then
188
+	fatal "doctl is not authenticated; run 'doctl auth init'"
189
+fi
190
+
191
+SSH_KEY_ID="$(doctl compute ssh-key list --output json | jq -r --arg name "$SSH_KEY_NAME" 'first(.[] | select(.name == $name) | .id) // ""')"
192
+[[ -n "$SSH_KEY_ID" ]] || fatal "no DigitalOcean SSH key named $SSH_KEY_NAME"
193
+
194
+PROJECT_ID="$(doctl projects list --output json | jq -r --arg name "$PROJECT_NAME" 'first(.[] | select(.name == $name) | .id) // ""')"
195
+if [[ -z "$PROJECT_ID" ]]; then
196
+	if (( DRY_RUN )); then
197
+		PROJECT_ID="dry-run-project-id"
198
+		log "would create project $PROJECT_NAME"
199
+	else
200
+		log "creating project $PROJECT_NAME"
201
+		PROJECT_ID="$(doctl projects create \
202
+			--name "$PROJECT_NAME" \
203
+			--purpose "Service or API" \
204
+			--environment Production \
205
+			--description "shithub Actions runner pool" \
206
+			--no-header --format ID)"
207
+	fi
208
+else
209
+	log "project $PROJECT_NAME exists ($PROJECT_ID)"
210
+fi
211
+
212
+ensure_tag() {
213
+	local tag="$1"
214
+	if doctl compute tag list --output json | jq -e --arg name "$tag" 'any(.[]; .name == $name)' >/dev/null; then
215
+		log "tag $tag exists"
216
+		return
217
+	fi
218
+	if (( DRY_RUN )); then
219
+		log "would create tag $tag"
220
+		return
221
+	fi
222
+	log "creating tag $tag"
223
+	doctl compute tag create "$tag" >/dev/null
224
+}
225
+
226
+ensure_tag "$RESOURCE_TAG"
227
+ensure_tag "$POOL_TAG"
228
+
229
+FIREWALL_ID="$(doctl compute firewall list --no-header --format ID,Name | awk -v n="$FIREWALL_NAME" '$2==n {print $1; exit}')"
230
+if [[ -z "$FIREWALL_ID" ]]; then
231
+	if (( DRY_RUN )); then
232
+		FIREWALL_ID="dry-run-firewall-id"
233
+		log "would create firewall $FIREWALL_NAME for tag $RESOURCE_TAG"
234
+	else
235
+		log "creating firewall $FIREWALL_NAME for tag $RESOURCE_TAG"
236
+		FIREWALL_ID="$(doctl compute firewall create \
237
+			--name "$FIREWALL_NAME" \
238
+			--tag-names "$RESOURCE_TAG" \
239
+			--inbound-rules "$SSH_INBOUND_RULES" \
240
+			--outbound-rules "$OUTBOUND_RULES" \
241
+			--no-header --format ID)"
242
+	fi
243
+else
244
+	log "firewall $FIREWALL_NAME exists ($FIREWALL_ID); leaving rules unchanged"
245
+fi
246
+
247
+NAME_PREFIX="shithub-runner-$POOL_NAME-"
248
+
249
+droplet_id_by_name() {
250
+	local name="$1"
251
+	doctl compute droplet list --no-header --format ID,Name | awk -v n="$name" '$2==n {print $1; exit}'
252
+}
253
+
254
+created_or_reused=()
255
+for i in $(seq 1 "$COUNT"); do
256
+	name="$NAME_PREFIX$i"
257
+	existing="$(droplet_id_by_name "$name")"
258
+	if [[ -n "$existing" ]]; then
259
+		log "droplet $name exists ($existing); skipping"
260
+		created_or_reused+=("$existing:$name:existing")
261
+		continue
262
+	fi
263
+
264
+	if (( DRY_RUN )); then
265
+		log "would create droplet $name ($REGION, $SIZE, $IMAGE)"
266
+		created_or_reused+=("dry-run-$i:$name:planned")
267
+		continue
268
+	fi
269
+
270
+	cmd=(doctl compute droplet create "$name"
271
+		--image "$IMAGE"
272
+		--region "$REGION"
273
+		--size "$SIZE"
274
+		--ssh-keys "$SSH_KEY_ID"
275
+		--enable-monitoring
276
+		--tag-names "$RESOURCE_TAG,$POOL_TAG"
277
+		--user-data-file "$USER_DATA_FILE"
278
+		--project-id "$PROJECT_ID"
279
+		--wait
280
+		--no-header
281
+		--format ID)
282
+	if [[ -n "$VPC_UUID" ]]; then
283
+		cmd+=(--vpc-uuid "$VPC_UUID")
284
+	fi
285
+
286
+	log "creating droplet $name ($REGION, $SIZE, $IMAGE)"
287
+	id="$("${cmd[@]}")"
288
+	created_or_reused+=("$id:$name:created")
289
+done
290
+
291
+if (( ! DRY_RUN )); then
292
+	resource_args=()
293
+	for entry in "${created_or_reused[@]}"; do
294
+		id="${entry%%:*}"
295
+		resource_args+=(--resource "do:droplet:$id")
296
+	done
297
+	if (( ${#resource_args[@]} > 0 )); then
298
+		log "assigning runner droplets to project $PROJECT_NAME"
299
+		doctl projects resources assign "$PROJECT_ID" "${resource_args[@]}" >/dev/null
300
+	fi
301
+fi
302
+
303
+if (( DRY_RUN )); then
304
+	droplets_json="$(
305
+		printf '%s\n' "${created_or_reused[@]}" |
306
+			jq -Rn '[inputs | split(":") | {
307
+				id: .[0],
308
+				name: .[1],
309
+				status: .[2],
310
+				public_ipv4: null,
311
+				private_ipv4: null
312
+			}]'
313
+	)"
314
+else
315
+	droplets_json="$(doctl compute droplet list --tag-name "$RESOURCE_TAG" --output json |
316
+		jq --arg prefix "$NAME_PREFIX" '[.[] | select(.name | startswith($prefix)) | {
317
+			id: (.id | tostring),
318
+			name: .name,
319
+			status: .status,
320
+			public_ipv4: ((.networks.v4 // []) | map(select(.type == "public")) | first | .ip_address // null),
321
+			private_ipv4: ((.networks.v4 // []) | map(select(.type == "private")) | first | .ip_address // null)
322
+		}] | sort_by(.name)')"
323
+fi
324
+
325
+jq -n \
326
+	--arg pool_name "$POOL_NAME" \
327
+	--arg project_name "$PROJECT_NAME" \
328
+	--arg project_id "$PROJECT_ID" \
329
+	--arg region "$REGION" \
330
+	--arg size "$SIZE" \
331
+	--arg image "$IMAGE" \
332
+	--arg resource_tag "$RESOURCE_TAG" \
333
+	--arg pool_tag "$POOL_TAG" \
334
+	--arg firewall_name "$FIREWALL_NAME" \
335
+	--arg firewall_id "$FIREWALL_ID" \
336
+	--argjson droplets "$droplets_json" \
337
+	'{
338
+		pool_name: $pool_name,
339
+		project: {name: $project_name, id: $project_id},
340
+		region: $region,
341
+		size: $size,
342
+		image: $image,
343
+		tags: [$resource_tag, $pool_tag],
344
+		firewall: {name: $firewall_name, id: $firewall_id},
345
+		droplets: $droplets
346
+	}'