fix(oci): services show their address instead of a web link, and applications are not asked for their own

This commit is contained in:
MacRimi
2026-10-11 15:57:05 +02:00
parent 873a3cd8e2
commit 69b76116fd
27 changed files with 407 additions and 18 deletions
+2 -1
View File
@@ -117,7 +117,8 @@
},
"environment_overrides": {
"APP_URL": {
"prompt": "Address BookStack will be opened at (for example http://192.168.1.50:6875)"
"prompt": "Address BookStack will be opened at (for example http://192.168.1.50)",
"container_address": "http://@CONTAINER_ADDRESS@"
},
"DB_HOST": {
"prompt": "Address of the MariaDB server"
+8
View File
@@ -1,4 +1,12 @@
{
"catalog_ui": {
"launch": {
"port": null
}
},
"first_run": {
"endpoints": []
},
"proxmox": {
"installer_profile": {
"volume_preparations": [
+2 -1
View File
@@ -16,7 +16,8 @@
"required": false
},
"SITE_ROOT": {
"prompt": "Address Healthchecks will be opened at (for example http://192.168.1.50:8000)"
"prompt": "Address Healthchecks will be opened at (for example http://192.168.1.50:8000)",
"container_address": "http://@CONTAINER_ADDRESS@:8000"
},
"SITE_NAME": {
"example": "Healthchecks"
+12
View File
@@ -1,6 +1,18 @@
{
"catalog_ui": {
"launch": {
"port": null
}
},
"first_run": {
"endpoints": []
},
"proxmox": {
"installer_profile": {
"completion_notes": [
"MariaDB has no web interface: applications and clients connect to port 3306 of this container.",
"The root password is asked for when connecting from another machine. Inside the container, the mariadb command opens as root without a password."
],
"volume_preparations": [
{
"container_path": "/config",
+2 -1
View File
@@ -215,7 +215,8 @@
"prompt": "Email address of the admin account"
},
"ALLOWED_HOST": {
"prompt": "Name or address NetBox will be opened at (for example netbox.example.com)"
"prompt": "Name or address NetBox will be opened at (for example netbox.example.com)",
"container_address": "@CONTAINER_ADDRESS@"
},
"DB_HOST": {
"prompt": "Address of the PostgreSQL server"
+8
View File
@@ -1,4 +1,12 @@
{
"catalog_ui": {
"launch": {
"port": null
}
},
"first_run": {
"endpoints": []
},
"proxmox": {
"installer_profile": {
"volume_preparations": [
+8
View File
@@ -1,4 +1,12 @@
{
"catalog_ui": {
"launch": {
"port": null
}
},
"first_run": {
"endpoints": []
},
"proxmox": {
"installer_profile": {
"volume_preparations": [
+2 -1
View File
@@ -142,7 +142,8 @@
},
"environment_overrides": {
"APP_URL": {
"prompt": "Address Speedtest Tracker will be opened at (for example http://192.168.1.50)"
"prompt": "Address Speedtest Tracker will be opened at (for example http://192.168.1.50)",
"container_address": "http://@CONTAINER_ADDRESS@"
},
"SPEEDTEST_SCHEDULE": {
"example": "0 */6 * * *",
+8
View File
@@ -1,4 +1,12 @@
{
"catalog_ui": {
"launch": {
"port": null
}
},
"first_run": {
"endpoints": []
},
"proxmox": {
"installer_profile": {
"volume_preparations": [
+64 -2
View File
@@ -2060,6 +2060,60 @@ CONSOLE_STATE=$(python3 "${SCRIPT_DIR}/oci_console.py" configure "$VMID") \
|| die "$(translate "The console of the container could not be configured")"
RUNTIME_CONSOLE_LOG=$(jq -r '.log' <<<"$CONSOLE_STATE")
# A variable that holds the application's own address takes the one of the
# container: the fixed one, or the one DHCP gives it. That one is asked for
# with the application not started yet, so that its first start already has it.
OWN_ADDRESS_MARK='@CONTAINER_ADDRESS@'
OWN_ADDRESS=""
apply_own_address() {
local names name value original inert="" candidate attempt
names=$(jq -r --arg mark "$OWN_ADDRESS_MARK" \
'.environment[]? | select(.value | contains($mark)) | .name' "$DEPLOYMENT_FILE")
[[ -n $names ]] || return 0
msg_info "$(translate "Waiting for the network address...")"
if [[ -n ${HOST_MONITOR:-} ]]; then
OWN_ADDRESS=$HOST_MONITOR_IP
elif [[ $IPV4 != dhcp ]]; then
OWN_ADDRESS=${IPV4%%/*}
else
mount_ct_rootfs
for candidate in /bin/sleep /usr/bin/sleep; do
if [[ -e /var/lib/lxc/${VMID}/rootfs${candidate} || -L /var/lib/lxc/${VMID}/rootfs${candidate} ]]; then
inert=$candidate
break
fi
done
oci_quiet pct unmount "$VMID" || true
if [[ -z $inert ]]; then
oci_log "The image has no sleep command to wait for the DHCP address with"
die "$(translate "No LAN address was obtained")"
fi
original=$(pct config "$VMID" | sed -n 's/^entrypoint: //p')
oci_quiet pct set "$VMID" --entrypoint "$inert 120" \
|| die "$(translate "Could not set the container entrypoint")"
oci_quiet pct start "$VMID" || die "$(translate "The container could not be started:") CT $VMID"
for attempt in $(seq 1 60); do
OWN_ADDRESS=$(own_ipv4 2>/dev/null) \
|| OWN_ADDRESS=$(lxc-info -n "$VMID" -iH 2>/dev/null | grep -m1 -E '^[0-9]+\.' || true)
[[ -n $OWN_ADDRESS ]] && break
sleep 0.5
done
oci_quiet pct stop "$VMID" || die "$(translate "The container could not be started:") CT $VMID"
if [[ -n $original ]]; then
oci_quiet pct set "$VMID" --entrypoint "$original" || die "$(translate "Could not set the container entrypoint")"
else
oci_quiet pct set "$VMID" --delete entrypoint || die "$(translate "Could not set the container entrypoint")"
fi
fi
[[ $OWN_ADDRESS =~ ^[0-9]+(\.[0-9]+){3}$ ]] || die "$(translate "No LAN address was obtained")"
while IFS= read -r name; do
value=$(jq -r --arg name "$name" '[.environment[] | select(.name == $name) | .value] | last' "$DEPLOYMENT_FILE")
set_runtime_environment "$name" "${value//$OWN_ADDRESS_MARK/$OWN_ADDRESS}"
done <<<"$names"
msg_ok "$(translate "IP address:") $OWN_ADDRESS"
}
apply_own_address
oci_log "Configuration created for CT $VMID"
PASSWORD_STATE=""
if [[ $START_AFTER == 1 ]]; then
@@ -2279,6 +2333,13 @@ URLS=$(jq -c --arg ip "$IP" '
}]
else [] end
' "$TEMPLATE_FILE")
# An application with no web address is reached on the ports of its container.
SERVICE_ADDRESSES=$(jq -c --arg ip "$IP" '
if $ip == "" or ((.first_run.endpoints? // []) | length) > 0 or .catalog_ui.launch.port != null then []
else [.container_contract.ports[]?
| $ip + ":" + (.container_port | tostring) + (if .protocol == "udp" then "/udp" else "" end)]
end
' "$TEMPLATE_FILE")
if [[ -n $HAOS_URLS ]]; then
URLS=$HAOS_URLS
elif [[ $HAOS_HEALTHCHECK != 0 ]]; then
@@ -2293,7 +2354,7 @@ elif [[ $START_AFTER == 1 ]]; then
check_native_device_permissions
fi
if [[ -z ${PROXMENUX_OCI_TRANSACTION:-} ]] && ! oci_quiet python3 "${SCRIPT_DIR}/oci_instances.py" complete "$VMID" \
--archive "$ARCHIVE_PATH" --digest "$DIGEST"; then
--archive "$ARCHIVE_PATH" --digest "$DIGEST" ${OWN_ADDRESS:+--address "$OWN_ADDRESS"}; then
msg_warn "$(translate "Container installed, but without a verifiable record for future updates.")"
fi
# The template's notes, then those of this installation's choices, such as the
@@ -2309,9 +2370,10 @@ RESULT=$(jq -cn \
--arg image_created "$CREATED" \
--arg ip "$IP" \
--argjson urls "$URLS" \
--argjson service_addresses "$SERVICE_ADDRESSES" \
--argjson credentials "$CREDENTIALS" \
--argjson completion_notes "$COMPLETION_NOTES" \
--argjson vmid "$VMID" \
--arg log "${OCI_LOG:-}" \
'{app_id:$app_id,vmid:$vmid,image:$image,digest:$digest,architecture:$architecture,image_version:$image_version,image_created:$image_created,ip:$ip,urls:$urls,credentials:$credentials,completion_notes:$completion_notes,log:$log}')
'{app_id:$app_id,vmid:$vmid,image:$image,digest:$digest,architecture:$architecture,image_version:$image_version,image_created:$image_created,ip:$ip,urls:$urls,service_addresses:$service_addresses,credentials:$credentials,completion_notes:$completion_notes,log:$log}')
printf 'PROXMENUX_RESULT=%s\n' "$(printf '%s' "$RESULT" | base64 | tr -d '\n')"
+7
View File
@@ -87,6 +87,13 @@ def render(template, digest, instance_id, ip=''):
item = f'&#127760; {link(str(endpoint.get("label") or "Web UI"), url)}: {link(url, url)}'
if item:
access.append(item)
if not access:
# With no web address, the application is reached on its ports.
for item in (template.get('container_contract') or {}).get('ports') or []:
port = item.get('container_port')
if isinstance(port, int) and 1 <= port <= 65535:
protocol = '/udp' if item.get('protocol') == 'udp' else ''
access.append(f'&#128268; <code>{address}:{port}{protocol}</code>')
badges = (
('Docs', DOCS, 'https://img.shields.io/badge/%F0%9F%93%9A_Docs-blue'),
('Code', CODE, 'https://img.shields.io/badge/%F0%9F%92%BB_Code-green'),
+10 -2
View File
@@ -24,6 +24,8 @@ from oci_ui import translate, msg_error, msg_ok
ROOT = Path('/usr/local/share/proxmenux/oci/instances')
MARKER = 'proxmenux-instance='
# Stands for the address of the container until the installation knows it.
ADDRESS_MARK = '@CONTAINER_ADDRESS@'
ACTIVE = {'installing', 'assembling', 'updating', 'recovering'}
@@ -160,8 +162,13 @@ def observe(vmid, installation_id, archive, digest, image=None):
return observed
def finish(root, vmid, archive, digest):
def finish(root, vmid, archive, digest, address=None):
value = read(root, vmid)
if address:
# The address the installation gave the application is recorded as the
# value it is, the way one typed by the user would be.
for item in value['deployment'].get('environment', []):
item['value'] = item['value'].replace(ADDRESS_MARK, address)
observed = observe(vmid, value['installation_id'], archive, digest)
value.update(status='assembling' if value['deployment'].get('stack_managed') else 'installed',
completed_at=now(), observed=observed)
@@ -356,6 +363,7 @@ def main():
if action == 'complete':
p.add_argument('--archive', required=True)
p.add_argument('--digest', required=True)
p.add_argument('--address')
sub.add_parser('reconcile')
resume = sub.add_parser('resume-stack-recording')
resume.add_argument('vmid', type=int)
@@ -372,7 +380,7 @@ def main():
json.loads(Path(args.deployment).read_text()))
print(value['installation_id'])
elif args.action == 'complete':
finish(args.root, args.vmid, args.archive, args.digest)
finish(args.root, args.vmid, args.archive, args.digest, args.address)
elif args.action == 'failed':
value = read(args.root, args.vmid)
value.update(status='failed', failed_at=now())
+1
View File
@@ -133,6 +133,7 @@
"example": {"type": ["string", "null"]},
"prompt": {"type": "string", "minLength": 1},
"prompt_user": {"type": "boolean"},
"container_address": {"type": "string", "pattern": "@CONTAINER_ADDRESS@"},
"required": {"type": "boolean"},
"sensitive": {"type": "boolean"},
"source": {"type": "string"}
+6 -1
View File
@@ -17,7 +17,9 @@ from .converter import ConversionError
from .github_source import SourceError
from .i18n import N_, source_text, translate
from .installer import (
ADDRESS_SHOWN,
ADVANCED_MODE,
CONTAINER_ADDRESS,
DEFAULT_MODE,
InstallError,
build_deployment,
@@ -345,7 +347,8 @@ def _deployment_summary_text(template: dict[str, Any], deployment: dict[str, Any
translate("unprivileged LXC") if security.get("unprivileged") else translate("privileged LXC"))
environment = plan.get("environment", [])
if environment:
row(translate("Variables"), ", ".join(f"{item['name']}={item['value']}" for item in environment))
row(translate("Variables"), ", ".join(
f"{item['name']}={item['value'].replace(CONTAINER_ADDRESS, ADDRESS_SHOWN)}" for item in environment))
if plan.get("suite_arr"):
lines += ["", *[translate(note) for note in plan.get("completion_notes", [])]]
lines.append("")
@@ -367,6 +370,8 @@ def _print_installation_summary(result: dict[str, Any], images_removed: str | No
console.msg_ok(f"{translate(STACK_LABELS.get(name, name))}: CT {vmid}")
for item in result.get("urls") or []:
console.msg_ok(f"{translate(item['label'])}: {item['url']}")
for address in result.get("service_addresses") or []:
console.msg_ok(f"{translate('Service:')} {address}")
for item in result.get("credentials") or []:
console.msg_info2(translate(item["label"]))
username = str(item["username"])
+23
View File
@@ -31,6 +31,20 @@ DEFAULT_MODE = "default"
ADVANCED_MODE = "advanced"
# Stands for the address of the container in a variable that holds the
# application's own address, until the installation knows it.
CONTAINER_ADDRESS = "@CONTAINER_ADDRESS@"
ADDRESS_SHOWN = "<IP>"
def own_address_value(pattern: str, ipv4: str) -> str:
"""The application's own address for a container with this network: the
fixed address when it has one, its mark when DHCP is still to give it."""
if ipv4 in ("dhcp", "host"):
return pattern
return pattern.replace(CONTAINER_ADDRESS, ipv4.split("/", 1)[0])
def _example_default(name: str, example: str | None, timezone: str | None = None) -> str | None:
defaults = {"PUID": "1000", "PGID": "1000", "TZ": timezone or host.timezone()}
if name in defaults:
@@ -584,6 +598,15 @@ def build_deployment(
label = f"{translate('Value for')} {name}"
default = _example_default(name, item["example"], timezone)
generated = installer_profile.get("generated_sensitive_environment", {}).get(name)
if item.get("container_address"):
# The address of the container is known or will be: nothing to ask.
# The advanced installation shows it, for a name or a proxy in front.
value = own_address_value(item["container_address"], ipv4)
if advanced:
shown = ui.ask(label, value.replace(CONTAINER_ADDRESS, ADDRESS_SHOWN), required=True)
value = shown.replace(ADDRESS_SHOWN, CONTAINER_ADDRESS)
environment.append({"name": name, "value": value, "sensitive": False})
continue
if item.get("prompt_user", True) is False:
if default in (None, "") and item["required"]:
raise InstallError(f"{name}: {translate('no declarative value')}")
+84
View File
@@ -0,0 +1,84 @@
"""An application that has to be told its own address is not asked for it: the
container has one, fixed or given by DHCP, and the installation uses that."""
import json
from pathlib import Path
import sys
import tempfile
import unittest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
sys.path.insert(0, str(ROOT / "remote"))
from proxmenux_oci.catalog import Catalog
from proxmenux_oci.installer import CONTAINER_ADDRESS, own_address_value
import oci_instances
# Application, its variable and the value it takes at 192.168.1.50.
OWN_ADDRESS = {
"bookstack": ("APP_URL", "http://192.168.1.50"),
"healthchecks": ("SITE_ROOT", "http://192.168.1.50:8000"),
"speedtest-tracker": ("APP_URL", "http://192.168.1.50"),
"netbox": ("ALLOWED_HOST", "192.168.1.50"),
}
class OwnAddressTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
catalog = Catalog(ROOT)
cls.variables = {}
for app, (name, _) in OWN_ADDRESS.items():
environment = catalog.compose(app)["container_contract"]["environment"]
cls.variables[app] = next(item for item in environment if item["name"] == name)
def test_a_fixed_address_is_used_as_it_is(self):
for app, (_, expected) in OWN_ADDRESS.items():
with self.subTest(app=app):
pattern = self.variables[app]["container_address"]
self.assertEqual(own_address_value(pattern, "192.168.1.50/24"), expected)
def test_dhcp_leaves_the_mark_for_the_installation(self):
for app in OWN_ADDRESS:
with self.subTest(app=app):
pattern = self.variables[app]["container_address"]
self.assertIn(CONTAINER_ADDRESS, own_address_value(pattern, "dhcp"))
def test_the_address_is_that_of_the_port_the_application_listens_on(self):
catalog = Catalog(ROOT)
for app, (_, expected) in OWN_ADDRESS.items():
with self.subTest(app=app):
endpoint = catalog.compose(app)["first_run"]["endpoints"][0]
if not expected.startswith("http"):
continue # A bare address names the machine, not a port.
port = "" if endpoint["port"] == 80 else f":{endpoint['port']}"
self.assertEqual(expected, f"{endpoint['scheme']}://192.168.1.50{port}")
def test_the_installer_resolves_the_mark_before_the_first_start(self):
script = (ROOT / "remote/install_oci.sh").read_text()
self.assertIn(f"OWN_ADDRESS_MARK='{CONTAINER_ADDRESS}'", script)
self.assertLess(script.index("\napply_own_address\n"), script.index('msg_info "$(translate "Starting the container...")"'))
self.assertEqual(oci_instances.ADDRESS_MARK, CONTAINER_ADDRESS)
def test_the_record_keeps_the_address_and_not_the_mark(self):
with tempfile.TemporaryDirectory() as folder:
root = Path(folder)
record = {"schema_version": 1, "vmid": 900, "installation_id": "00000000-0000-4000-8000-000000000000", "status": "installing",
"deployment": {"environment": [{"name": "APP_URL", "value": f"http://{CONTAINER_ADDRESS}"},
{"name": "TZ", "value": "Europe/Madrid"}]}}
path = oci_instances.location(root, 900)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(record))
observe, oci_instances.observe = oci_instances.observe, lambda *args, **kwargs: {"config": ""}
try:
oci_instances.finish(root, 900, "archive", "digest", "192.168.1.50")
finally:
oci_instances.observe = observe
saved = json.loads(path.read_text())["deployment"]["environment"]
self.assertEqual(saved[0]["value"], "http://192.168.1.50")
self.assertEqual(saved[1]["value"], "Europe/Madrid")
if __name__ == "__main__":
unittest.main()
+78
View File
@@ -0,0 +1,78 @@
"""A port that does not speak HTTP is not announced as a web address: the link
would open nothing, and an update would wait on it for an answer that never
comes and undo itself."""
from pathlib import Path
import sys
import unittest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
sys.path.insert(0, str(ROOT / "remote"))
from proxmenux_oci.catalog import Catalog
import oci_description
import oci_instance_transaction
# Application and the port its recipe was generated with as web address.
SERVICES = {
"mariadb": 3306,
"ngircd": 6667,
"openssh-server": 2222,
"syslog-ng": 6601,
"faster-whisper": 10300,
"piper": 10200,
}
class ServicePortTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
catalog = Catalog(ROOT)
cls.templates = {app: catalog.compose(app) for app in SERVICES}
def test_no_web_address_is_announced(self):
for app, template in self.templates.items():
with self.subTest(app=app):
self.assertEqual(template["first_run"]["endpoints"], [])
self.assertIsNone(template["catalog_ui"]["launch"]["port"])
def test_the_port_is_still_the_one_of_the_container(self):
for app, port in SERVICES.items():
with self.subTest(app=app):
ports = [item["container_port"] for item in self.templates[app]["container_contract"]["ports"]]
self.assertIn(port, ports)
def test_an_update_checks_that_the_container_keeps_running(self):
for app, template in self.templates.items():
with self.subTest(app=app):
self.assertEqual(oci_instance_transaction.effective_healthcheck(template)["type"], "running")
def test_the_notes_give_the_address_as_text(self):
notes = oci_description.render(self.templates["mariadb"], "sha256:" + "0" * 64, "instance", "192.168.1.50")
self.assertIn("<code>192.168.1.50:3306</code>", notes)
self.assertNotIn("Web UI", notes)
self.assertNotIn("http://192.168.1.50", notes)
notes = oci_description.render(self.templates["syslog-ng"], "sha256:" + "0" * 64, "instance", "192.168.1.50")
self.assertIn("<code>192.168.1.50:5514/udp</code>", notes)
def test_a_web_application_keeps_its_link_in_the_notes(self):
template = Catalog(ROOT).compose("bookstack")
notes = oci_description.render(template, "sha256:" + "0" * 64, "instance", "192.168.1.50")
self.assertIn("http://192.168.1.50:80/", notes)
self.assertNotIn("<code>192.168.1.50:", notes)
def test_the_summary_gives_the_address_of_a_service_only(self):
script = (ROOT / "remote/install_oci.sh").read_text()
self.assertIn("service_addresses:$service_addresses", script)
cli = (ROOT / "src/proxmenux_oci/cli.py").read_text()
self.assertIn("translate('Service:')", cli)
def test_mariadb_says_how_to_connect(self):
notes = self.templates["mariadb"]["proxmox"]["installer_profile"]["completion_notes"]
self.assertTrue(any("port 3306" in note for note in notes))
self.assertTrue(any("without a password" in note for note in notes))
if __name__ == "__main__":
unittest.main()