Not a flake. IP allocation lived in the else-branch of "did the database return
any rows for this app", so it ran only when the app held ZERO rows. An app with
even one row skipped the loop entirely, and a service without a row never got an
IP and never would. Its IP_TAG_<n> stayed unfilled, the literal IP_DATA_<n>
reached the compose, and docker refused the app with
invalid IPv4 address: ParseAddr("IP_DATA_3")
which surfaced as "no container started (image pull failed?)". Nothing repaired
it: reinstalling re-ran the same skip, so the app stayed broken until someone
uninstalled it and wiped the rows.
Partial state is not exotic — an app that GAINS a service in a later version hits
this on its very next install, because the old services still hold rows. That is
the case worth worrying about; Stoat only got there by being installed and
uninstalled repeatedly.
Reproduced deterministically by deleting one row from a healthy 16-service Stoat:
the install reported "No IP allocated for service: stoat-rabbit" as a NOTICE,
then "Success: Updated 15 IP tag system", then failed at compose. After the fix
the same broken state self-heals — "Allocated IP: stoat/stoat-rabbit" — with no
uninstall.
Three more bugs in the same path, all found while tracing it:
- ipFindAvailable tested pool membership with a substring match against the
newline-joined list of allocated IPs, so .4 read as taken whenever .46 or .147
existed. Demonstrated: with 3 addresses allocated it excluded 5. Harmless at
low occupancy, but it silently shrinks the pool as it fills and would report
exhaustion early. Now an exact whole-line match.
- ipFindAvailable set available_ip="" on an exhausted pool and carried on to
index the empty array, where RANDOM % 0 is a division-by-zero that would bury
the real message. ipAllocation did the same and still ran its INSERT, writing
a row with an empty resource_value — which then satisfied "this service has an
allocation" forever after, making the service unrepairable. Both now return.
- first_allocated_ip was only assigned inside the allocate branch, so on every
reinstall (where rows already exist) it came out empty and the trusted-domains
list shipped with a hole. Now taken from the mapping.
An unfilled tag is also an error rather than a notice now: the compose is
unshippable at that point, and reporting "Success: updated 15 IP tags" is how
this reached the user as a confusing pull failure several steps later. The
install backstop no longer guesses "(image pull failed?)" either — that guess
was written for one cause and misdirects for every other.
Verified: clean install allocates all 16 with no unfilled tags, a deliberately
broken row self-heals, and no duplicate IPs exist across any app.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
60 lines
2.4 KiB
Bash
Executable File
60 lines
2.4 KiB
Bash
Executable File
#!/bin/bash
|
|
|
|
# Find available IP in pool with randomization
|
|
ipFindAvailable()
|
|
{
|
|
# Extract subnet base from CFG_NETWORK_SUBNET and use hardcoded sensible defaults
|
|
local subnet_base=$(echo "$CFG_NETWORK_SUBNET" | cut -d'/' -f1 | cut -d'.' -f1-3)
|
|
local start_last=2 # Hardcoded sensible default: .2
|
|
local end_last=254 # Hardcoded sensible default: .254
|
|
|
|
local existing_ips; existing_ips=$(runInstallOp sqlite3 "$docker_dir/$db_file" "SELECT resource_value FROM network_resources WHERE resource_type = 'ip' AND status = 'active';" 2>/dev/null)
|
|
if [[ $? -ne 0 ]]; then
|
|
isError "Database query failed while checking existing IPs"
|
|
available_ip=""
|
|
return 1
|
|
fi
|
|
|
|
# Exact whole-line matching. The membership test used to be a substring check
|
|
# against the newline-joined list, so .4 read as taken whenever .46 or .147
|
|
# existed — silently shrinking a /24 pool to the addresses that happen not to
|
|
# be a prefix of another. Wrapping both sides in newlines makes the match
|
|
# exact, which is also why the delimiters are added rather than compared
|
|
# element by element: it stays one string test.
|
|
existing_ips=$'\n'"${existing_ips}"$'\n'
|
|
|
|
# Create an array to store available IPs
|
|
local available_ip_pool=()
|
|
|
|
# Randomization: Shuffle the IP range
|
|
local ip_range=($(seq $start_last $end_last | shuf))
|
|
|
|
for i in "${ip_range[@]}"; do
|
|
# Skip reserved IPs (1=gateway, 254=broadcast)
|
|
if [[ $i -eq 1 || $i -eq 254 ]]; then
|
|
continue
|
|
fi
|
|
|
|
local test_ip="${subnet_base}.${i}"
|
|
|
|
# Check if IP is already allocated
|
|
if [[ "$existing_ips" != *$'\n'"$test_ip"$'\n'* ]]; then
|
|
available_ip_pool+=("$test_ip")
|
|
fi
|
|
done
|
|
|
|
# Check if we have any available IPs
|
|
if [[ ${#available_ip_pool[@]} -eq 0 ]]; then
|
|
isError "No available IP addresses in subnet $CFG_NETWORK_SUBNET and all expansion ranges exhausted"
|
|
available_ip=""
|
|
# Returning matters: the line below indexes the pool, and RANDOM % 0 is a
|
|
# division-by-zero that would bury the real "pool exhausted" message under
|
|
# a bash arithmetic error.
|
|
return 1
|
|
fi
|
|
|
|
# Randomly select an IP from the available pool
|
|
local random_index=$((RANDOM % ${#available_ip_pool[@]}))
|
|
available_ip="${available_ip_pool[$random_index]}"
|
|
}
|