homelab infrastructure services
1#!/bin/bash
2# Machine metadata management functions
3# Creates and manages machine metadata in station-prod
4
5# Source required libraries
6# Note: Assume core.sh is already sourced by caller
7# We need uid.sh for parse_machine_name() and calculate_service_uid()
8LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
9source "$LIB_DIR/uid.sh"
10
11# Get the path to a machine's metadata directory in station-prod
12get_machine_metadata_dir() {
13 local machine_name="$1"
14 local machine_env="$2"
15 local sheet="${TIN_SHEET:-topsheet}"
16
17 echo "/mnt/station-prod/data/machines/${sheet}/${machine_name}-${machine_env}"
18}
19
20# Create machine metadata directory structure in station-prod
21create_machine_metadata() {
22 local machine_name="$1"
23 local machine_env="$2"
24 local machine_uid="$3"
25 local sheet="${TIN_SHEET:-topsheet}"
26
27 local metadata_dir=$(get_machine_metadata_dir "$machine_name" "$machine_env")
28
29 log_with_prefix "Metadata" "Creating machine metadata in station-prod"
30 log_with_prefix "Metadata" "Directory: $metadata_dir"
31
32 # Create metadata directory (as station-prod user)
33 if ! sudo -u station-prod mkdir -p "$metadata_dir"; then
34 error_with_prefix "Metadata" "Failed to create metadata directory"
35 return 1
36 fi
37
38 # Generate machine.env
39 if ! generate_machine_env "$machine_name" "$machine_env" "$machine_uid"; then
40 error_with_prefix "Metadata" "Failed to generate machine.env"
41 return 1
42 fi
43
44 # Initialize ports registry
45 if ! initialize_ports_registry "$machine_name" "$machine_env" "$machine_uid"; then
46 error_with_prefix "Metadata" "Failed to initialize ports registry"
47 return 1
48 fi
49
50 log_with_prefix "Metadata" "Machine metadata created successfully"
51 return 0
52}
53
54# Generate machine.env file with infrastructure variables
55generate_machine_env() {
56 local machine_name="$1"
57 local machine_env="$2"
58 local machine_uid="$3"
59 local sheet="${TIN_SHEET:-topsheet}"
60
61 local metadata_dir=$(get_machine_metadata_dir "$machine_name" "$machine_env")
62 local env_file="$metadata_dir/machine.env"
63 local mount_point="/mnt/${machine_name}-${machine_env}"
64
65 log_with_prefix "Metadata" "Generating machine.env"
66
67 # Create machine.env with infrastructure variables
68 sudo -u station-prod tee "$env_file" > /dev/null << EOF
69# Tinsnip Machine Environment
70# Machine: ${machine_name}-${machine_env}
71# Sheet: ${sheet}
72# Created: $(date '+%Y-%m-%d %H:%M:%S')
73
74# Machine Identity
75TIN_MACHINE_NAME=${machine_name}
76TIN_MACHINE_ENVIRONMENT=${machine_env}
77TIN_SERVICE_UID=${machine_uid}
78TIN_SHEET=${sheet}
79
80# XDG Base Directory (NFS-backed)
81XDG_DATA_HOME=${mount_point}/data
82XDG_CONFIG_HOME=${mount_point}/config
83XDG_STATE_HOME=${mount_point}/state
84
85# Docker Environment (populated during machine setup)
86# These will be added by Docker setup process if applicable
87EOF
88
89 if [[ $? -eq 0 ]]; then
90 log_with_prefix "Metadata" "machine.env created: $env_file"
91 return 0
92 else
93 error_with_prefix "Metadata" "Failed to create machine.env"
94 return 1
95 fi
96}
97
98# Initialize empty ports registry file
99initialize_ports_registry() {
100 local machine_name="$1"
101 local machine_env="$2"
102 local machine_uid="$3"
103 local sheet="${TIN_SHEET:-topsheet}"
104
105 local metadata_dir=$(get_machine_metadata_dir "$machine_name" "$machine_env")
106 local ports_file="$metadata_dir/ports"
107 local max_port=$((machine_uid + 9))
108
109 log_with_prefix "Metadata" "Initializing ports registry"
110
111 # Create empty ports registry with header
112 sudo -u station-prod tee "$ports_file" > /dev/null << EOF
113# Port Allocation Registry
114# Machine: ${machine_name}-${machine_env} (UID ${machine_uid})
115# Port range: ${machine_uid}-${max_port} (10 ports total)
116# Format: catalog_service:start_port:num_ports
117
118EOF
119
120 if [[ $? -eq 0 ]]; then
121 log_with_prefix "Metadata" "ports registry created: $ports_file"
122 return 0
123 else
124 error_with_prefix "Metadata" "Failed to create ports registry"
125 return 1
126 fi
127}
128
129# Create .machine symlink from machine mount to station-prod metadata
130create_machine_symlink() {
131 local machine_name="$1"
132 local machine_env="$2"
133 local sheet="${TIN_SHEET:-topsheet}"
134
135 local mount_point="/mnt/${machine_name}-${machine_env}"
136 local metadata_dir=$(get_machine_metadata_dir "$machine_name" "$machine_env")
137 local symlink="$mount_point/.machine"
138
139 log_with_prefix "Metadata" "Creating .machine symlink"
140 log_with_prefix "Metadata" "From: $symlink"
141 log_with_prefix "Metadata" "To: $metadata_dir"
142
143 # Create symlink (as machine user to ensure correct ownership)
144 if ! sudo -u "${machine_name}-${machine_env}" ln -sfn "$metadata_dir" "$symlink"; then
145 error_with_prefix "Metadata" "Failed to create .machine symlink"
146 return 1
147 fi
148
149 # Verify symlink
150 if [[ -L "$symlink" ]]; then
151 local target=$(readlink "$symlink")
152 log_with_prefix "Metadata" "Symlink created successfully"
153 log_with_prefix "Metadata" "Verified: $symlink -> $target"
154 return 0
155 else
156 error_with_prefix "Metadata" "Symlink verification failed"
157 return 1
158 fi
159}
160
161# Append Docker environment variables to machine.env
162# Called after Docker installation completes
163append_docker_env_to_metadata() {
164 local machine_name="$1"
165 local machine_env="$2"
166 local machine_uid="$3"
167
168 local metadata_dir=$(get_machine_metadata_dir "$machine_name" "$machine_env")
169 local env_file="$metadata_dir/machine.env"
170
171 log_with_prefix "Metadata" "Appending Docker environment to machine.env"
172
173 # Append Docker-specific variables
174 sudo -u station-prod tee -a "$env_file" > /dev/null << EOF
175
176# Docker Environment (added after Docker installation)
177DOCKER_HOST=unix:///run/user/${machine_uid}/docker.sock
178PATH=/home/${machine_name}-${machine_env}/.local/bin:/usr/local/bin:/usr/bin:/bin
179EOF
180
181 if [[ $? -eq 0 ]]; then
182 log_with_prefix "Metadata" "Docker environment added to machine.env"
183 return 0
184 else
185 warn_with_prefix "Metadata" "Failed to append Docker environment (non-fatal)"
186 return 0 # Non-fatal - Docker setup may not always be needed
187 fi
188}
189
190# ============================================================================
191# Port Allocation Functions
192# ============================================================================
193
194# Get number of ports a service needs by parsing docker-compose.yml
195# Returns count of unique TIN_PORT_* references, defaults to 1 if none found
196get_service_port_count() {
197 local compose_file="$1"
198
199 if [[ ! -f "$compose_file" ]]; then
200 log_with_prefix "Port Count" "docker-compose.yml not found, defaulting to 1 port"
201 echo "1"
202 return 0
203 fi
204
205 # Extract unique TIN_PORT_* variable references
206 local port_count=$(grep -o '\${TIN_PORT_[0-9]\+}' "$compose_file" 2>/dev/null | \
207 sed 's/\${TIN_PORT_\([0-9]\+\)}/\1/' | \
208 sort -u | \
209 wc -l | \
210 tr -d ' ')
211
212 # Default to 1 if no ports found
213 if [[ -z "$port_count" || "$port_count" -eq 0 ]]; then
214 log_with_prefix "Port Count" "No TIN_PORT_* references found, defaulting to 1 port"
215 echo "1"
216 else
217 log_with_prefix "Port Count" "Service requires $port_count port(s)"
218 echo "$port_count"
219 fi
220
221 return 0
222}
223
224# Read port allocations from registry file
225# Output format: One line per allocation: "service_name start_port num_ports"
226read_port_allocations() {
227 local ports_file="$1"
228
229 if [[ ! -f "$ports_file" ]]; then
230 # Empty registry
231 return 0
232 fi
233
234 # Parse registry, skip comments and empty lines
235 grep -v '^#' "$ports_file" | grep -v '^[[:space:]]*$' || true
236}
237
238# Find next available port range using first-fit algorithm
239# Returns start_port or error if exhausted
240find_available_port_range() {
241 local ports_file="$1"
242 local machine_uid="$2"
243 local num_ports="$3"
244
245 local max_port=$((machine_uid + 9))
246 local min_port=$machine_uid
247
248 # Read existing allocations and sort by start_port
249 local allocations=$(read_port_allocations "$ports_file" | sort -t: -k2 -n)
250
251 # Track current position (start of search range)
252 local current=$min_port
253
254 # Check each allocation to find gaps
255 while IFS=: read -r service start num; do
256 [[ -z "$service" ]] && continue
257
258 local start_port=$((start))
259 local allocated_num=$((num))
260
261 # Is there a gap before this allocation?
262 if [[ $((start_port - current)) -ge $num_ports ]]; then
263 # Found a gap that fits!
264 echo "$current"
265 return 0
266 fi
267
268 # Move past this allocation
269 current=$((start_port + allocated_num))
270 done <<< "$allocations"
271
272 # Check if there's space after the last allocation
273 if [[ $((max_port - current + 1)) -ge $num_ports ]]; then
274 echo "$current"
275 return 0
276 fi
277
278 # No space available
279 return 1
280}
281
282# Allocate ports for a catalog service
283# Returns allocated start_port or exits on error
284allocate_service_ports() {
285 local machine_name="$1"
286 local machine_env="$2"
287 local catalog_service="$3"
288 local num_ports="$4"
289
290 local sheet="${TIN_SHEET:-topsheet}"
291 local metadata_dir=$(get_machine_metadata_dir "$machine_name" "$machine_env")
292 local ports_file="$metadata_dir/ports"
293
294 # Get machine UID
295 local machine_uid
296 if ! machine_uid=$(calculate_service_uid "$machine_name" "$machine_env" 2>/dev/null); then
297 error_with_prefix "Port Allocation" "Cannot calculate UID for machine"
298 return 1
299 fi
300
301 local max_port=$((machine_uid + 9))
302
303 log_with_prefix "Port Allocation" "Allocating $num_ports port(s) for $catalog_service"
304
305 # Validate port count
306 if [[ $num_ports -lt 1 || $num_ports -gt 10 ]]; then
307 error_with_prefix "Port Allocation" "Invalid port count: $num_ports (must be 1-10)"
308 return 1
309 fi
310
311 # Check if service already allocated
312 local existing_allocation
313 if existing_allocation=$(read_port_allocations "$ports_file" | grep "^${catalog_service}:"); then
314 # Service already deployed - check if port count matches
315 local existing_start=$(echo "$existing_allocation" | cut -d: -f2)
316 local existing_count=$(echo "$existing_allocation" | cut -d: -f3)
317
318 if [[ $existing_count -eq $num_ports ]]; then
319 # Port allocation matches - reuse it (idempotent deployment)
320 log_with_prefix "Port Allocation" "Reusing existing allocation: $existing_start-$((existing_start + existing_count - 1)) ($existing_count ports)"
321 echo "$existing_start"
322 return 0
323 else
324 # Port count mismatch - need to reallocate
325 error_with_prefix "Port Allocation" "Service port count changed"
326 echo " Service: $catalog_service" >&2
327 echo " Current allocation: $existing_count port(s)" >&2
328 echo " Required: $num_ports port(s)" >&2
329 echo "" >&2
330 echo " To reallocate ports:" >&2
331 echo " 1. Remove: tin service rm ${machine_name}-${machine_env} $catalog_service" >&2
332 echo " 2. Deploy: tin service deploy ${machine_name}-${machine_env} $catalog_service" >&2
333 return 1
334 fi
335 fi
336
337 # Find available port range
338 local start_port
339 if ! start_port=$(find_available_port_range "$ports_file" "$machine_uid" "$num_ports"); then
340 error_with_prefix "Port Allocation" "Insufficient ports available"
341 echo " Machine: ${machine_name}-${machine_env} (UID $machine_uid)" >&2
342 echo " Port range: $machine_uid-$max_port (10 ports total)" >&2
343 echo " Requested: $num_ports ports" >&2
344 echo "" >&2
345 echo " Current allocations:" >&2
346 read_port_allocations "$ports_file" | while IFS=: read -r svc start num; do
347 echo " $svc: $start-$((start + num - 1)) ($num ports)" >&2
348 done
349 echo "" >&2
350 echo " Suggestions:" >&2
351 echo " - Remove unused services: tin service rm ${machine_name}-${machine_env} <service>" >&2
352 echo " - Create new machine: tin machine create ${machine_name}2 $machine_env <nas-server>" >&2
353 return 1
354 fi
355
356 # Validate allocation doesn't exceed max port
357 local end_port=$((start_port + num_ports - 1))
358 if [[ $end_port -gt $max_port ]]; then
359 error_with_prefix "Port Allocation" "Port allocation would exceed machine range"
360 echo " Calculated range: $start_port-$end_port" >&2
361 echo " Maximum allowed: $max_port" >&2
362 return 1
363 fi
364
365 # Append to registry (will be sorted)
366 local temp_file=$(mktemp)
367
368 # Copy header and existing allocations
369 if [[ -f "$ports_file" ]]; then
370 grep '^#' "$ports_file" > "$temp_file" || true
371 read_port_allocations "$ports_file" >> "$temp_file"
372 fi
373
374 # Add new allocation
375 echo "${catalog_service}:${start_port}:${num_ports}" >> "$temp_file"
376
377 # Sort allocations by start_port and write back
378 (
379 grep '^#' "$temp_file" || true
380 grep -v '^#' "$temp_file" | grep -v '^[[:space:]]*$' | sort -t: -k2 -n || true
381 ) | sudo -u station-prod tee "$ports_file" > /dev/null
382
383 rm -f "$temp_file"
384
385 log_with_prefix "Port Allocation" "✓ Allocated ports $start_port-$end_port ($num_ports ports)"
386
387 echo "$start_port"
388 return 0
389}
390
391# Deallocate ports for a catalog service
392deallocate_service_ports() {
393 local machine_env="$1"
394 local catalog_service="$2"
395
396 # Parse machine-environment
397 local parsed_output
398 if ! parsed_output=$(parse_machine_name "$machine_env" 2>/dev/null); then
399 error_with_prefix "Port Deallocation" "Invalid machine environment: $machine_env"
400 return 1
401 fi
402
403 local machine_name=$(echo "$parsed_output" | sed -n '1p')
404 local environment=$(echo "$parsed_output" | sed -n '2p')
405
406 local metadata_dir=$(get_machine_metadata_dir "$machine_name" "$environment")
407 local ports_file="$metadata_dir/ports"
408
409 if [[ ! -f "$ports_file" ]]; then
410 log_with_prefix "Port Deallocation" "No ports registry found, skipping"
411 return 0
412 fi
413
414 # Check if service has allocation
415 if ! grep -q "^${catalog_service}:" "$ports_file"; then
416 log_with_prefix "Port Deallocation" "No port allocation found for $catalog_service"
417 return 0
418 fi
419
420 log_with_prefix "Port Deallocation" "Freeing ports for $catalog_service"
421
422 # Get allocation details for logging
423 local allocation=$(grep "^${catalog_service}:" "$ports_file")
424 local start_port=$(echo "$allocation" | cut -d: -f2)
425 local num_ports=$(echo "$allocation" | cut -d: -f3)
426 local end_port=$((start_port + num_ports - 1))
427
428 # Remove allocation (preserve header, remove service line)
429 # Create temp file in a location station-prod can write to
430 local temp_file="/tmp/tinsnip-ports-dealloc-$$"
431 sudo -u station-prod bash -c "grep -v '^${catalog_service}:' '$ports_file' > '$temp_file' && mv '$temp_file' '$ports_file'"
432
433 log_with_prefix "Port Deallocation" "✓ Freed ports $start_port-$end_port ($num_ports ports)"
434
435 return 0
436}
437
438# Generate service .env file with port allocations
439generate_service_env() {
440 local service_env="$1"
441 local catalog_service="$2"
442 local start_port="$3"
443 local num_ports="$4"
444
445 local service_dir="/mnt/${service_env}/service/${catalog_service}"
446 local env_file="${service_dir}/.env"
447
448 log_with_prefix "Service Env" "Generating service .env file"
449
450 # Ensure service directory exists
451 if [[ ! -d "$service_dir" ]]; then
452 error_with_prefix "Service Env" "Service directory not found: $service_dir"
453 return 1
454 fi
455
456 local end_port=$((start_port + num_ports - 1))
457
458 # Generate .env file as service user
459 sudo -u "$service_env" tee "$env_file" > /dev/null << EOF
460# Tinsnip Service Environment
461# Catalog Service: ${catalog_service}
462# Machine: ${service_env}
463# Allocated Ports: ${start_port}-${end_port} (${num_ports} ports)
464# Generated: $(date '+%Y-%m-%d %H:%M:%S')
465
466TIN_CATALOG_SERVICE=${catalog_service}
467EOF
468
469 # Append port variables
470 for ((i=0; i<num_ports; i++)); do
471 echo "TIN_PORT_${i}=$((start_port + i))" | sudo -u "$service_env" tee -a "$env_file" > /dev/null
472 done
473
474 log_with_prefix "Service Env" "✓ Created: $env_file"
475
476 return 0
477}