mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-05 04:12:10 +00:00
fix(oci): let AdGuard Home pass its update check on the web port once it is set up
This commit is contained in:
@@ -358,13 +358,13 @@ cleanup_canonical_inputs() {
|
|||||||
|
|
||||||
# A derived check accepts any HTTP answer below 500: the application is up,
|
# A derived check accepts any HTTP answer below 500: the application is up,
|
||||||
# even when its first page is a redirect or asks for a login.
|
# even when its first page is a redirect or asks for a login.
|
||||||
healthcheck_probe() {
|
healthcheck_probe_url() {
|
||||||
local code status=0
|
local url=$1 code status=0
|
||||||
if [[ ${HC_ANY_STATUS:-false} == true ]]; then
|
if [[ ${HC_ANY_STATUS:-false} == true ]]; then
|
||||||
code=$(oci_curl "$VMID" "${CURL_ARGS[@]}" "$HC_URL" 2>/dev/null) || status=$?
|
code=$(oci_curl "$VMID" "${CURL_ARGS[@]}" "$url" 2>/dev/null) || status=$?
|
||||||
(( status == 0 )) && [[ $code =~ ^[1-4][0-9][0-9]$ ]] && return 0
|
(( status == 0 )) && [[ $code =~ ^[1-4][0-9][0-9]$ ]] && return 0
|
||||||
else
|
else
|
||||||
oci_curl "$VMID" "${CURL_ARGS[@]}" "$HC_URL" >/dev/null 2>&1 || status=$?
|
oci_curl "$VMID" "${CURL_ARGS[@]}" "$url" >/dev/null 2>&1 || status=$?
|
||||||
(( status == 0 )) && return 0
|
(( status == 0 )) && return 0
|
||||||
fi
|
fi
|
||||||
# A port taken from the compose file may serve a database or SSH: an answer
|
# A port taken from the compose file may serve a database or SSH: an answer
|
||||||
@@ -373,6 +373,19 @@ healthcheck_probe() {
|
|||||||
&& (( status == 1 || status == 8 || status == 52 || status == 56 ))
|
&& (( status == 1 || status == 8 || status == 52 || status == 56 ))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Any of the addresses answering means the application is up; HC_URL keeps the
|
||||||
|
# one that answered.
|
||||||
|
healthcheck_probe() {
|
||||||
|
local url
|
||||||
|
for url in "${HC_URLS[@]}"; do
|
||||||
|
if healthcheck_probe_url "$url"; then
|
||||||
|
HC_URL=$url
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
cleanup_failed_install() {
|
cleanup_failed_install() {
|
||||||
local status=$?
|
local status=$?
|
||||||
stop_spinner
|
stop_spinner
|
||||||
@@ -2333,6 +2346,12 @@ if [[ $START_AFTER == 1 && $HAS_STARTUP_HEALTHCHECK == 1 ]]; then
|
|||||||
|| die "$(translate "The stability period must be shorter than the healthcheck timeout")"
|
|| die "$(translate "The stability period must be shorter than the healthcheck timeout")"
|
||||||
[[ -n $IP ]] || die "$(translate "The healthcheck cannot run without an IP address")"
|
[[ -n $IP ]] || die "$(translate "The healthcheck cannot run without an IP address")"
|
||||||
HC_URL="${HC_SCHEME}://${IP}:${HC_PORT}${HC_PATH}"
|
HC_URL="${HC_SCHEME}://${IP}:${HC_PORT}${HC_PATH}"
|
||||||
|
HC_URLS=("$HC_URL")
|
||||||
|
while IFS= read -r HC_ALT_PORT; do
|
||||||
|
[[ $HC_ALT_PORT =~ ^[0-9]+$ && $HC_ALT_PORT -ge 1 && $HC_ALT_PORT -le 65535 ]] \
|
||||||
|
|| die "$(translate "Invalid healthcheck port")"
|
||||||
|
HC_URLS+=("${HC_SCHEME}://${IP}:${HC_ALT_PORT}${HC_PATH}")
|
||||||
|
done < <(jq -r '.alternative_ports[]? | tostring' <<<"$STARTUP_HEALTHCHECK")
|
||||||
HC_ANY_STATUS=$(jq -r '.accept_any_status // false' <<<"$STARTUP_HEALTHCHECK")
|
HC_ANY_STATUS=$(jq -r '.accept_any_status // false' <<<"$STARTUP_HEALTHCHECK")
|
||||||
HC_ACCEPT_NON_HTTP=$(jq -r '.accept_non_http // false' <<<"$STARTUP_HEALTHCHECK")
|
HC_ACCEPT_NON_HTTP=$(jq -r '.accept_non_http // false' <<<"$STARTUP_HEALTHCHECK")
|
||||||
if [[ $HC_ANY_STATUS == true ]]; then
|
if [[ $HC_ANY_STATUS == true ]]; then
|
||||||
|
|||||||
@@ -435,13 +435,22 @@ def probe_http(args):
|
|||||||
return result.stdout, result.returncode
|
return result.stdout, result.returncode
|
||||||
|
|
||||||
|
|
||||||
|
def setup_then_web_port(template, check):
|
||||||
|
"""AdGuard Home answers its setup wizard on 3000 and, once set up, its web UI
|
||||||
|
on 80. A check on 3000, derived or saved with an installation, takes 80 too."""
|
||||||
|
if (template.get('id') == 'image-adguard-home' and check.get('port') == 3000
|
||||||
|
and not check.get('alternative_ports')):
|
||||||
|
return dict(check, alternative_ports=[80])
|
||||||
|
return check
|
||||||
|
|
||||||
|
|
||||||
def effective_healthcheck(template):
|
def effective_healthcheck(template):
|
||||||
"""The template's own health check or, when it has none, one derived from
|
"""The template's own health check or, when it has none, one derived from
|
||||||
its web address: any HTTP answer below 500 means the application is up."""
|
its web address: any HTTP answer below 500 means the application is up."""
|
||||||
profile = template.get('proxmox', {}).get('installer_profile', {})
|
profile = template.get('proxmox', {}).get('installer_profile', {})
|
||||||
check = profile.get('startup_healthcheck')
|
check = profile.get('startup_healthcheck')
|
||||||
if check:
|
if check:
|
||||||
return check
|
return setup_then_web_port(template, check)
|
||||||
if profile.get('haos_healthcheck'):
|
if profile.get('haos_healthcheck'):
|
||||||
# Home Assistant OS has a check of its own: Supervisor, Core and the
|
# Home Assistant OS has a check of its own: Supervisor, Core and the
|
||||||
# services it runs inside. The installer runs it on the new container.
|
# services it runs inside. The installer runs it on the new container.
|
||||||
@@ -453,6 +462,7 @@ def effective_healthcheck(template):
|
|||||||
check = {'scheme': endpoint['scheme'], 'port': int(endpoint['port']), 'path': path,
|
check = {'scheme': endpoint['scheme'], 'port': int(endpoint['port']), 'path': path,
|
||||||
'timeout_seconds': 300, 'request_timeout_seconds': 10, 'stability_seconds': 4,
|
'timeout_seconds': 300, 'request_timeout_seconds': 10, 'stability_seconds': 4,
|
||||||
'verify_tls': False, 'accept_any_status': True}
|
'verify_tls': False, 'accept_any_status': True}
|
||||||
|
check = setup_then_web_port(template, check)
|
||||||
# A port taken from the compose file because the recipe names no
|
# A port taken from the compose file because the recipe names no
|
||||||
# web address may serve a database or SSH: any answer counts.
|
# web address may serve a database or SSH: any answer counts.
|
||||||
if endpoint.get('source') == GUESSED_ENDPOINT:
|
if endpoint.get('source') == GUESSED_ENDPOINT:
|
||||||
@@ -923,10 +933,12 @@ def healthcheck(vmid, template):
|
|||||||
time.sleep(2)
|
time.sleep(2)
|
||||||
log(f'container {vmid} kept running for {stability}s')
|
log(f'container {vmid} kept running for {stability}s')
|
||||||
return None
|
return None
|
||||||
scheme, port, path = check['scheme'], int(check['port']), check['path']
|
scheme, path = check['scheme'], check['path']
|
||||||
|
# Any of the ports answering means the application is up.
|
||||||
|
ports = [int(check['port']), *(int(p) for p in check.get('alternative_ports') or [])]
|
||||||
timeout = int(check.get('timeout_seconds', 120))
|
timeout = int(check.get('timeout_seconds', 120))
|
||||||
stability = int(check.get('stability_seconds', 0))
|
stability = int(check.get('stability_seconds', 0))
|
||||||
if (scheme not in ('http', 'https') or not 1 <= port <= 65535
|
if (scheme not in ('http', 'https') or not all(1 <= port <= 65535 for port in ports)
|
||||||
or not path.startswith('/') or any(c.isspace() for c in path)
|
or not path.startswith('/') or any(c.isspace() for c in path)
|
||||||
or not 0 <= stability < timeout <= 3600):
|
or not 0 <= stability < timeout <= 3600):
|
||||||
raise ValueError(translate('Invalid health check'))
|
raise ValueError(translate('Invalid health check'))
|
||||||
@@ -947,10 +959,13 @@ def healthcheck(vmid, template):
|
|||||||
args += ['-w', '%{http_code}']
|
args += ['-w', '%{http_code}']
|
||||||
if not check.get('verify_tls', False):
|
if not check.get('verify_tls', False):
|
||||||
args.append('-k')
|
args.append('-k')
|
||||||
|
for port in ports:
|
||||||
answer, status = probe_http([*args, f'{scheme}://{ip}:{port}{path}'])
|
answer, status = probe_http([*args, f'{scheme}://{ip}:{port}{path}'])
|
||||||
ok = status == 0 and (not any_status or re.fullmatch(rb'[1-4][0-9][0-9]', answer.strip()) is not None)
|
ok = status == 0 and (not any_status or re.fullmatch(rb'[1-4][0-9][0-9]', answer.strip()) is not None)
|
||||||
if not ok and check.get('accept_non_http') and status in NON_HTTP_ANSWER:
|
if not ok and check.get('accept_non_http') and status in NON_HTTP_ANSWER:
|
||||||
ok = True
|
ok = True
|
||||||
|
if ok:
|
||||||
|
break
|
||||||
if ok:
|
if ok:
|
||||||
stable_since = stable_since or time.monotonic()
|
stable_since = stable_since or time.monotonic()
|
||||||
if time.monotonic() - stable_since >= stability:
|
if time.monotonic() - stable_since >= stability:
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ DECLARED = {'first_run': {'endpoints': [{'label': 'Web UI', 'scheme': 'http', 'p
|
|||||||
|
|
||||||
def probe_function():
|
def probe_function():
|
||||||
source = INSTALLER.read_text()
|
source = INSTALLER.read_text()
|
||||||
start = source.index('healthcheck_probe() {')
|
start = source.index('healthcheck_probe_url() {')
|
||||||
end = source.index('\n}\n', start) + 3
|
end = source.index('\n}\n', source.index('healthcheck_probe() {')) + 3
|
||||||
return source[start:end]
|
return source[start:end]
|
||||||
|
|
||||||
|
|
||||||
@@ -38,7 +38,7 @@ class GuessedEndpointCheckTests(unittest.TestCase):
|
|||||||
fake = Path(directory) / 'curl'
|
fake = Path(directory) / 'curl'
|
||||||
fake.write_text(f'#!/bin/sh\nprintf %s {code}\nexit {exit_code}\n')
|
fake.write_text(f'#!/bin/sh\nprintf %s {code}\nexit {exit_code}\n')
|
||||||
fake.chmod(0o755)
|
fake.chmod(0o755)
|
||||||
script = (probe_function() + '\noci_curl() { shift; curl "$@"; }\nCURL_ARGS=()\nHC_URL=http://192.0.2.1:3306/\n'
|
script = (probe_function() + '\noci_curl() { shift; curl "$@"; }\nCURL_ARGS=()\nHC_URLS=(http://192.0.2.1:3306/)\n'
|
||||||
f'HC_ANY_STATUS={any_status}\nHC_ACCEPT_NON_HTTP={accept_non_http}\nhealthcheck_probe\n')
|
f'HC_ANY_STATUS={any_status}\nHC_ACCEPT_NON_HTTP={accept_non_http}\nhealthcheck_probe\n')
|
||||||
env = {**os.environ, 'PATH': f'{directory}:{os.environ["PATH"]}'}
|
env = {**os.environ, 'PATH': f'{directory}:{os.environ["PATH"]}'}
|
||||||
return subprocess.run(['bash', '-c', script], env=env, capture_output=True).returncode
|
return subprocess.run(['bash', '-c', script], env=env, capture_output=True).returncode
|
||||||
@@ -83,5 +83,62 @@ class GuessedEndpointCheckTests(unittest.TestCase):
|
|||||||
transaction.healthcheck(113, template)
|
transaction.healthcheck(113, template)
|
||||||
|
|
||||||
|
|
||||||
|
ADGUARD = {'id': 'image-adguard-home',
|
||||||
|
'first_run': {'endpoints': [{'label': 'Web UI', 'scheme': 'http', 'port': 3000, 'path': '/'}]}}
|
||||||
|
|
||||||
|
|
||||||
|
class SetupThenWebPortTests(unittest.TestCase):
|
||||||
|
"""AdGuard Home answers on 3000 until it is set up, then on 80: an update of
|
||||||
|
a configured installation must not wait for the setup port."""
|
||||||
|
|
||||||
|
def test_adguard_also_accepts_its_web_port(self):
|
||||||
|
check = transaction.effective_healthcheck(ADGUARD)
|
||||||
|
self.assertEqual((check['port'], check['alternative_ports']), (3000, [80]))
|
||||||
|
self.assertNotIn('alternative_ports', transaction.effective_healthcheck(DECLARED))
|
||||||
|
|
||||||
|
def test_a_check_saved_with_an_installation_takes_the_web_port_too(self):
|
||||||
|
# An installation stores the check it was given: still on 3000 only.
|
||||||
|
saved = {'scheme': 'http', 'port': 3000, 'path': '/', 'timeout_seconds': 300, 'accept_any_status': True}
|
||||||
|
template = dict(ADGUARD, proxmox={'installer_profile': {'startup_healthcheck': saved}})
|
||||||
|
self.assertEqual(transaction.effective_healthcheck(template)['alternative_ports'], [80])
|
||||||
|
self.assertNotIn('alternative_ports', saved)
|
||||||
|
other = dict(template, id='image-other')
|
||||||
|
self.assertNotIn('alternative_ports', transaction.effective_healthcheck(other))
|
||||||
|
|
||||||
|
def test_recovery_check_passes_on_the_web_port_when_setup_is_closed(self):
|
||||||
|
check = transaction.effective_healthcheck(ADGUARD)
|
||||||
|
check.update(timeout_seconds=6, stability_seconds=0)
|
||||||
|
template = {'proxmox': {'installer_profile': {'startup_healthcheck': check}}}
|
||||||
|
|
||||||
|
def run(*args):
|
||||||
|
if args[:2] == ('pct', 'status'):
|
||||||
|
return b'status: running\n'
|
||||||
|
if args[:2] == ('pct', 'config'):
|
||||||
|
return b''
|
||||||
|
if args[0] == 'lxc-info':
|
||||||
|
return b'192.0.2.10\n'
|
||||||
|
raise AssertionError(args)
|
||||||
|
|
||||||
|
def probe(args):
|
||||||
|
return (b'200', 0) if args[-1].endswith(':80/') else (b'000', 7)
|
||||||
|
with patch.object(transaction, 'run', side_effect=run), \
|
||||||
|
patch.object(transaction, 'probe_http', side_effect=probe), \
|
||||||
|
patch.object(transaction, 'log'):
|
||||||
|
self.assertEqual(transaction.healthcheck(101, template), 'http://192.0.2.10:80/')
|
||||||
|
|
||||||
|
def test_installer_probe_tries_every_address_and_keeps_the_one_that_answered(self):
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
fake = Path(directory) / 'curl'
|
||||||
|
fake.write_text('#!/bin/sh\nfor last; do :; done\ncase "$last" in *:80/) printf 200; exit 0;; esac\n'
|
||||||
|
'printf 000\nexit 7\n')
|
||||||
|
fake.chmod(0o755)
|
||||||
|
script = (probe_function() + '\noci_curl() { shift; curl "$@"; }\nCURL_ARGS=()\n'
|
||||||
|
'HC_URLS=(http://192.0.2.1:3000/ http://192.0.2.1:80/)\nHC_ANY_STATUS=true\n'
|
||||||
|
'healthcheck_probe && echo "$HC_URL"\n')
|
||||||
|
env = {**os.environ, 'PATH': f'{directory}:{os.environ["PATH"]}'}
|
||||||
|
done = subprocess.run(['bash', '-c', script], env=env, capture_output=True, text=True)
|
||||||
|
self.assertEqual((done.returncode, done.stdout.strip()), (0, 'http://192.0.2.1:80/'))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user