mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-11-02 19:02:31 +00:00
feat(oci): VLAN tag and updates that keep Proxmox changes
This commit is contained in:
@@ -11,6 +11,7 @@ import traceback
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from oci_installation_state import container_curl
|
||||
from oci_ui import log, msg_progress, translate
|
||||
|
||||
|
||||
@@ -79,7 +80,14 @@ def containers_ready(items):
|
||||
and bool(by_name['homeassistant'].get('Config', {}).get('Image')))
|
||||
|
||||
|
||||
def http_ready(url, timeout=5):
|
||||
def http_ready(vmid, url, timeout=5):
|
||||
curl = container_curl(vmid, lambda *args: capture(args).encode())
|
||||
if curl != ['curl']:
|
||||
try:
|
||||
return capture([*curl, '-sS', '--noproxy', '*', '-o', '/dev/null', '-w', '%{http_code}',
|
||||
'--max-time', str(timeout), url], timeout=timeout + 1).strip() == '200'
|
||||
except subprocess.SubprocessError:
|
||||
return False
|
||||
# Do not route private healthchecks through an environment HTTP proxy.
|
||||
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
||||
try:
|
||||
@@ -111,10 +119,10 @@ def probe(vmid, ip, remaining):
|
||||
supervisor = json.loads(run(docker + ['exec', 'hassio_cli', 'ha', '--raw-json', 'supervisor', 'info']))
|
||||
if not supervisor_ready(supervisor):
|
||||
raise Pending(translate('Supervisor does not confirm healthy and supported yet'))
|
||||
if not http_ready(f'http://{ip}:4357/', max(0.1, min(5, remaining()))):
|
||||
if not http_ready(vmid, f'http://{ip}:4357/', max(0.1, min(5, remaining()))):
|
||||
raise Pending(translate('Observer is not responding on port 4357'))
|
||||
for port in (8123, 80):
|
||||
if http_ready(f'http://{ip}:{port}/', max(0.1, min(5, remaining()))):
|
||||
if http_ready(vmid, f'http://{ip}:{port}/', max(0.1, min(5, remaining()))):
|
||||
return [{'label': 'Home Assistant', 'url': f'http://{ip}:{port}/'},
|
||||
{'label': 'Home Assistant Observer', 'url': f'http://{ip}:4357/'}]
|
||||
raise Pending(translate('Core is running, but not responding over HTTP on 80/8123'))
|
||||
|
||||
@@ -662,6 +662,16 @@ def attach_mounts(service, temporary):
|
||||
oci_nested_mounts.follow(vmid)
|
||||
|
||||
|
||||
def web_answers(vmid, url, opener):
|
||||
from oci_installation_state import container_curl
|
||||
curl = container_curl(vmid, lambda *args: run(*args).encode())
|
||||
if curl != ['curl']:
|
||||
run(*curl, '-fsS', '--noproxy', '*', '-o', '/dev/null', '--max-time', '4', url)
|
||||
return True
|
||||
with opener.open(url,timeout=4) as response:
|
||||
return response.status<400
|
||||
|
||||
|
||||
def wait_web(primary, url):
|
||||
deadline = time.monotonic()+primary['healthcheck']['timeout_seconds']
|
||||
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
||||
@@ -669,16 +679,15 @@ def wait_web(primary, url):
|
||||
if run('pct','status',primary['vmid']).strip()!='status: running':
|
||||
raise RuntimeError(f"{translate('The container stopped:')} {primary['name']} (CT {primary['vmid']})")
|
||||
try:
|
||||
with opener.open(url,timeout=4) as response:
|
||||
if response.status<400:
|
||||
check = primary['healthcheck']
|
||||
if check.get('type') == 'exec':
|
||||
remaining = max(1, deadline-time.monotonic())
|
||||
process = subprocess.run(['pct', 'exec', str(primary['vmid']), '--', *check['argv']],
|
||||
capture_output=True, timeout=min(30, remaining), check=False)
|
||||
if process.returncode != 0:
|
||||
raise RuntimeError('Health check has not succeeded yet')
|
||||
return
|
||||
if web_answers(primary['vmid'], url, opener):
|
||||
check = primary['healthcheck']
|
||||
if check.get('type') == 'exec':
|
||||
remaining = max(1, deadline-time.monotonic())
|
||||
process = subprocess.run(['pct', 'exec', str(primary['vmid']), '--', *check['argv']],
|
||||
capture_output=True, timeout=min(30, remaining), check=False)
|
||||
if process.returncode != 0:
|
||||
raise RuntimeError('Health check has not succeeded yet')
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
if time.monotonic()>=deadline:
|
||||
@@ -1050,7 +1059,9 @@ def main():
|
||||
run('pct','set',s['vmid'],'--startup',f'order={order*10},up=5,down=30')
|
||||
if s['main'] or s.get('frontend'):
|
||||
address = access_address(s.get('frontend_ipv4') or 'dhcp', deployment['network'].get('frontend_gateway'))
|
||||
run('pct','set',s['vmid'],'--net1',f"name=eth1,bridge={deployment['network']['frontend_bridge']},{address},host-managed=1,firewall=1,type=veth")
|
||||
vlan_tag = deployment['network'].get('frontend_vlan_tag')
|
||||
tag = f",tag={int(vlan_tag)}" if vlan_tag else ''
|
||||
run('pct','set',s['vmid'],'--net1',f"name=eth1,bridge={deployment['network']['frontend_bridge']},{address}{tag},host-managed=1,firewall=1,type=veth")
|
||||
assign_health_address(s)
|
||||
# Existing CT/network configurations now reserve every allocated ID and subnet.
|
||||
# Never retain the allocation lock while startup/hooks wait for another registry operation.
|
||||
|
||||
@@ -140,10 +140,11 @@ ML_FRONTEND_IPV4=$(jq -r '.network.machine_learning_frontend_ipv4 // "dhcp"' "$D
|
||||
FRONTEND_GATEWAY=$(jq -r '.network.frontend_gateway // empty' "$DEPLOYMENT_FILE")
|
||||
oci_access_net "$FRONTEND_IPV4" "$FRONTEND_GATEWAY" \
|
||||
|| die "$(translate "Invalid access address:") $FRONTEND_IPV4 $FRONTEND_GATEWAY"
|
||||
FRONTEND_NET=$OCI_ACCESS_NET
|
||||
oci_vlan_option frontend_vlan_tag || die "$(translate "The VLAN tag must be a number from 1 to 4094")"
|
||||
FRONTEND_NET=$OCI_ACCESS_NET$OCI_VLAN_OPTION
|
||||
oci_access_net "$ML_FRONTEND_IPV4" "$FRONTEND_GATEWAY" \
|
||||
|| die "$(translate "Invalid access address:") $ML_FRONTEND_IPV4 $FRONTEND_GATEWAY"
|
||||
ML_FRONTEND_NET=$OCI_ACCESS_NET
|
||||
ML_FRONTEND_NET=$OCI_ACCESS_NET$OCI_VLAN_OPTION
|
||||
PRIVATE_BRIDGE=$(jqr '.network.private_bridge')
|
||||
PRIVATE_SUBNET=$(jqr '.network.private_subnet')
|
||||
PRIVATE_HOST_ADDRESS=$(jqr '.network.private_host_address')
|
||||
|
||||
@@ -132,7 +132,8 @@ FRONTEND_IPV4=$(jqr '.network.frontend_ipv4')
|
||||
FRONTEND_GATEWAY=$(jq -r '.network.frontend_gateway // empty' "$DEPLOYMENT_FILE")
|
||||
oci_access_net "$FRONTEND_IPV4" "$FRONTEND_GATEWAY" \
|
||||
|| die "$(translate "Invalid access address:") $FRONTEND_IPV4 $FRONTEND_GATEWAY"
|
||||
FRONTEND_NET=$OCI_ACCESS_NET
|
||||
oci_vlan_option frontend_vlan_tag || die "$(translate "The VLAN tag must be a number from 1 to 4094")"
|
||||
FRONTEND_NET=$OCI_ACCESS_NET$OCI_VLAN_OPTION
|
||||
PRIVATE_BRIDGE=$(jqr '.network.private_bridge')
|
||||
PRIVATE_SUBNET=$(jqr '.network.private_subnet')
|
||||
PRIVATE_HOST_ADDRESS=$(jqr '.network.private_host_address')
|
||||
@@ -511,7 +512,7 @@ if (( START_AFTER == 1 )); then
|
||||
| grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' \
|
||||
| grep -vFx "$APPLICATION_IP" | head -n 1 || true)
|
||||
if [[ -n $APPLICATION_LAN_IP ]] \
|
||||
&& curl -fsS -H 'Host: localhost' \
|
||||
&& oci_curl "$APPLICATION_ID" -fsS -H 'Host: localhost' \
|
||||
"http://${APPLICATION_LAN_IP}/status.php" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
@@ -521,7 +522,7 @@ if (( START_AFTER == 1 )); then
|
||||
|| die "$(translate "The application did not get an address on the access network:") Nextcloud"
|
||||
# Nextcloud 34 validates status.php against trusted_domains before the LAN IP
|
||||
# can be registered. localhost is the official image's initial trusted host.
|
||||
STATUS_JSON=$(curl -fsS -H 'Host: localhost' \
|
||||
STATUS_JSON=$(oci_curl "$APPLICATION_ID" -fsS -H 'Host: localhost' \
|
||||
"http://${APPLICATION_LAN_IP}/status.php")
|
||||
jq -e '.installed == true and .maintenance == false and .needsDbUpgrade == false' \
|
||||
<<<"$STATUS_JSON" >/dev/null \
|
||||
|
||||
@@ -361,10 +361,10 @@ cleanup_canonical_inputs() {
|
||||
healthcheck_probe() {
|
||||
local code status=0
|
||||
if [[ ${HC_ANY_STATUS:-false} == true ]]; then
|
||||
code=$(curl "${CURL_ARGS[@]}" "$HC_URL" 2>/dev/null) || status=$?
|
||||
code=$(oci_curl "$VMID" "${CURL_ARGS[@]}" "$HC_URL" 2>/dev/null) || status=$?
|
||||
(( status == 0 )) && [[ $code =~ ^[1-4][0-9][0-9]$ ]] && return 0
|
||||
else
|
||||
curl "${CURL_ARGS[@]}" "$HC_URL" >/dev/null 2>&1 || status=$?
|
||||
oci_curl "$VMID" "${CURL_ARGS[@]}" "$HC_URL" >/dev/null 2>&1 || status=$?
|
||||
(( status == 0 )) && return 0
|
||||
fi
|
||||
# A port taken from the compose file may serve a database or SSH: an answer
|
||||
@@ -1044,12 +1044,13 @@ apply_extra_hosts() {
|
||||
}
|
||||
|
||||
# Settings an update gives back to the rebuilt container before it starts:
|
||||
# the LAN leg of a suite or stack member, its start order and current tags.
|
||||
# the LAN leg of a suite or stack member, its start order and current tags,
|
||||
# and what the user set in Proxmox.
|
||||
apply_kept_settings() {
|
||||
local key value
|
||||
while IFS=$'\t' read -r key value; do
|
||||
[[ -n $key ]] || continue
|
||||
[[ $key == net1 || $key == startup || $key == tags ]] \
|
||||
[[ $key =~ ^(net[01]|startup|tags|features|nameserver|searchdomain|timezone|cpulimit|cmode|console|tty|hookscript|entrypoint)$ ]] \
|
||||
|| die "$(translate "Unsupported kept setting:") $key"
|
||||
if [[ $key == tags && -z $value ]]; then
|
||||
oci_quiet pct set "$VMID" --delete tags
|
||||
@@ -1057,6 +1058,13 @@ apply_kept_settings() {
|
||||
oci_quiet pct set "$VMID" "--$key" "$value"
|
||||
fi || die "$(translate "Could not restore the container setting:") $key"
|
||||
done < <(jq -r '.kept_proxmox_settings // {} | to_entries[] | [.key, .value] | @tsv' "$DEPLOYMENT_FILE")
|
||||
while IFS= read -r value; do
|
||||
[[ -n $value ]] || continue
|
||||
[[ $value =~ ^lxc\.[A-Za-z0-9._-]+:\ ]] \
|
||||
|| die "$(translate "Unsupported kept setting:") $value"
|
||||
printf '%s\n' "$value" >>"$CONF" \
|
||||
|| die "$(translate "Could not restore the container setting:") $value"
|
||||
done < <(jq -r '.custom_lxc // [] | .[]' "$DEPLOYMENT_FILE")
|
||||
}
|
||||
|
||||
apply_runtime_user() {
|
||||
@@ -1714,6 +1722,8 @@ NET="name=eth0,bridge=${BRIDGE},firewall=${FIREWALL},host-managed=1,ip=${IPV4},t
|
||||
if [[ -n $GATEWAY ]]; then
|
||||
NET="${NET},gw=${GATEWAY}"
|
||||
fi
|
||||
oci_vlan_option vlan_tag || die "$(translate "The VLAN tag must be a number from 1 to 4094")"
|
||||
NET="${NET}${OCI_VLAN_OPTION}"
|
||||
if [[ -n $MAC_ADDRESS ]]; then
|
||||
[[ $MAC_ADDRESS =~ ^[0-9A-Fa-f]{2}(:[0-9A-Fa-f]{2}){5}$ ]] \
|
||||
|| die "$(translate "Invalid MAC address:") $MAC_ADDRESS"
|
||||
|
||||
@@ -132,7 +132,8 @@ FRONTEND_IPV4=$(jqr '.network.frontend_ipv4')
|
||||
FRONTEND_GATEWAY=$(jq -r '.network.frontend_gateway // empty' "$DEPLOYMENT_FILE")
|
||||
oci_access_net "$FRONTEND_IPV4" "$FRONTEND_GATEWAY" \
|
||||
|| die "$(translate "Invalid access address:") $FRONTEND_IPV4 $FRONTEND_GATEWAY"
|
||||
FRONTEND_NET=$OCI_ACCESS_NET
|
||||
oci_vlan_option frontend_vlan_tag || die "$(translate "The VLAN tag must be a number from 1 to 4094")"
|
||||
FRONTEND_NET=$OCI_ACCESS_NET$OCI_VLAN_OPTION
|
||||
PRIVATE_BRIDGE=$(jqr '.network.private_bridge')
|
||||
PRIVATE_SUBNET=$(jqr '.network.private_subnet')
|
||||
PRIVATE_HOST_ADDRESS=$(jqr '.network.private_host_address')
|
||||
@@ -513,7 +514,7 @@ if (( START_AFTER == 1 )); then
|
||||
| grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' \
|
||||
| grep -vFx "$APPLICATION_IP" | head -n 1 || true)
|
||||
if [[ -n $APPLICATION_LAN_IP ]] \
|
||||
&& curl -fsS "http://${APPLICATION_LAN_IP}:8000/" >/dev/null 2>&1; then
|
||||
&& oci_curl "$APPLICATION_ID" -fsS "http://${APPLICATION_LAN_IP}:8000/" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
if [[ $(pct status "$APPLICATION_ID" 2>/dev/null) != *running* ]]; then
|
||||
@@ -523,7 +524,7 @@ if (( START_AFTER == 1 )); then
|
||||
done
|
||||
[[ -n $APPLICATION_LAN_IP ]] \
|
||||
|| die "$(translate "The application did not get an address on the access network:") Paperless-ngx"
|
||||
curl -fsS "http://${APPLICATION_LAN_IP}:8000/" >/dev/null 2>>"$OCI_LOG" \
|
||||
oci_curl "$APPLICATION_ID" -fsS "http://${APPLICATION_LAN_IP}:8000/" >/dev/null 2>>"$OCI_LOG" \
|
||||
|| die "$(translate "The application did not pass its HTTP check:") Paperless-ngx"
|
||||
msg_ok "$(translate "Application responding:") http://${APPLICATION_LAN_IP}:8000/"
|
||||
fi
|
||||
|
||||
@@ -145,7 +145,8 @@ FRONTEND_IPV4=$(jqr '.network.frontend_ipv4')
|
||||
FRONTEND_GATEWAY=$(jq -r '.network.frontend_gateway // empty' "$DEPLOYMENT_FILE")
|
||||
oci_access_net "$FRONTEND_IPV4" "$FRONTEND_GATEWAY" \
|
||||
|| die "$(translate "Invalid access address:") $FRONTEND_IPV4 $FRONTEND_GATEWAY"
|
||||
FRONTEND_NET=$OCI_ACCESS_NET
|
||||
oci_vlan_option frontend_vlan_tag || die "$(translate "The VLAN tag must be a number from 1 to 4094")"
|
||||
FRONTEND_NET=$OCI_ACCESS_NET$OCI_VLAN_OPTION
|
||||
PRIVATE_BRIDGE=$(jqr '.network.private_bridge')
|
||||
PRIVATE_SUBNET=$(jqr '.network.private_subnet')
|
||||
PRIVATE_HOST_ADDRESS=$(jqr '.network.private_host_address')
|
||||
@@ -475,7 +476,7 @@ if (( START_AFTER == 1 )); then
|
||||
| grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' \
|
||||
| grep -vFx "$APPLICATION_IP" | head -n 1 || true)
|
||||
if [[ -n $APPLICATION_LAN_IP ]] \
|
||||
&& curl -fsS "http://${APPLICATION_LAN_IP}/" >/dev/null 2>&1; then
|
||||
&& oci_curl "$APPLICATION_ID" -fsS "http://${APPLICATION_LAN_IP}/" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
if [[ $(pct status "$APPLICATION_ID" 2>/dev/null) != *running* ]]; then
|
||||
@@ -488,7 +489,7 @@ if (( START_AFTER == 1 )); then
|
||||
print_first_boot_diagnostics "$APPLICATION_ID" tandoor
|
||||
die "$(translate "The application did not get an address on the access network:") Tandoor"
|
||||
fi
|
||||
curl -fsS "http://${APPLICATION_LAN_IP}/" >/dev/null 2>>"$OCI_LOG" \
|
||||
oci_curl "$APPLICATION_ID" -fsS "http://${APPLICATION_LAN_IP}/" >/dev/null 2>>"$OCI_LOG" \
|
||||
|| { print_first_boot_diagnostics "$APPLICATION_ID" tandoor; \
|
||||
die "$(translate "The application did not pass its HTTP check:") Tandoor"; }
|
||||
msg_ok "$(translate "Application responding:") http://${APPLICATION_LAN_IP}/"
|
||||
|
||||
@@ -136,6 +136,16 @@ def parse_config(data):
|
||||
return values
|
||||
|
||||
|
||||
def container_curl(vmid, run=command):
|
||||
"""curl for an address of this container. The host may have no route to the
|
||||
VLAN of an interface: there it runs inside the network of the container."""
|
||||
config = parse_config(run('pct', 'config', str(vmid)))
|
||||
if not any(re.fullmatch(r'net[0-9]+', key) and re.search(r'(^|,)tag=', value) for key, value in config.items()):
|
||||
return ['curl']
|
||||
pid = run('lxc-info', '-n', str(vmid), '-pH').decode().strip()
|
||||
return ['nsenter', f'--net=/proc/{pid}/ns/net', 'curl']
|
||||
|
||||
|
||||
def private_directory(path):
|
||||
path.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
if path.is_symlink() or path.stat().st_uid != os.geteuid():
|
||||
|
||||
@@ -94,15 +94,11 @@ ENVIRONMENT_KEYS = ('env', 'lxc.environment.runtime')
|
||||
|
||||
def _environment(record, config):
|
||||
"""Variables changed in Proxmox since the last operation. Values are
|
||||
applied but never listed: they may be passwords."""
|
||||
applied but never listed: they may be passwords. A variable the image
|
||||
defines and the user removed comes back with its value, as in Docker."""
|
||||
before = native_modify.environment(record['observed']['config'].encode())
|
||||
now = native_modify.environment(config)
|
||||
defaults = {item.split('=', 1)[0] for item in record['observed']['image'].get('defaults', {}).get('Env', [])
|
||||
if '=' in item}
|
||||
removed = sorted(before.keys() - now.keys())
|
||||
# An image default comes back with a new container; its absence cannot be kept.
|
||||
if any(name in defaults for name in removed):
|
||||
raise ValueError(f"{translate('These manual changes cannot be adopted safely:')} env")
|
||||
changed = sorted(name for name in now if before.get(name) != now[name])
|
||||
return {name: now[name] for name in changed}, removed
|
||||
|
||||
@@ -126,14 +122,30 @@ def _adopt_environment(deployment, values, removed):
|
||||
return details
|
||||
|
||||
|
||||
def propose(record, config):
|
||||
if (record.get('status') != 'installed' or record.get('stack') or record.get('stack_member')
|
||||
or record.get('native_stack_intent') or record.get('deployment', {}).get('stack_managed')):
|
||||
def _storage_change(key, value, deployment, vmid):
|
||||
"""A disk resized or moved in Proxmox: the record follows it."""
|
||||
if key == 'rootfs':
|
||||
storage = value.split(':', 1)[0]
|
||||
size = dict(part.split('=', 1) for part in value.split(',')[1:] if '=' in part).get('size')
|
||||
deployment['rootfs'].update(storage=storage, size_gb=_managed_size(size))
|
||||
return
|
||||
current = _mount(key, value, vmid)
|
||||
for mount in deployment.get('mounts', []):
|
||||
if mount.get('container_path') == current['container_path']:
|
||||
mount.update({field: current[field] for field in ('source', 'size_gb', 'read_only')})
|
||||
|
||||
|
||||
def propose(record, config, member=False):
|
||||
if (record.get('status') != 'installed' or (not member and (
|
||||
record.get('stack') or record.get('stack_member') or record.get('native_stack_intent')
|
||||
or record.get('deployment', {}).get('stack_managed')))):
|
||||
raise ValueError(translate('Only an installed standalone OCI instance can adopt external changes'))
|
||||
if instances.identity(config) != record['installation_id']:
|
||||
raise ValueError(translate('The container identity changed; nothing was adopted'))
|
||||
before = parse_config(record['observed']['config'].encode())
|
||||
current = parse_config(config)
|
||||
if transaction.nested_mounts.stale(config):
|
||||
transaction.nested_mounts.follow(record['vmid'])
|
||||
config = instances.command('pct', 'config', str(record['vmid']))
|
||||
before, current = transaction.settings(record['observed']['config'].encode()), transaction.settings(config)
|
||||
changed = sorted(key for key in before.keys() | current.keys() if before.get(key) != current.get(key))
|
||||
# Notes do not describe a mount, device or runtime setting. The OCI marker
|
||||
# was checked above, so a presentation-only change needs no adoption.
|
||||
@@ -143,16 +155,41 @@ def propose(record, config):
|
||||
if any(key in ENVIRONMENT_KEYS for key in changed):
|
||||
changed = [key for key in changed if key not in ENVIRONMENT_KEYS]
|
||||
environment = _environment(record, config)
|
||||
custom = transaction.custom_lxc(record, config)
|
||||
custom_keys = {line.split(': ', 1)[0] for line in (*custom, *record['deployment'].get('custom_lxc', []))}
|
||||
custom_changed = sorted(custom) != sorted(record['deployment'].get('custom_lxc', []))
|
||||
changed = [key for key in changed if key not in custom_keys]
|
||||
new_keys = [key for key in changed if key not in before and re.fullmatch(r'(mp|dev)[0-9]+', key)]
|
||||
unsupported = [key for key in changed if key not in new_keys and key not in transaction.ADOPTABLE
|
||||
and key not in transaction.KEPT_AS_IS]
|
||||
storage = [key for key in changed if key in current and key in before and re.fullmatch(r'rootfs|mp[0-9]+', key)]
|
||||
detached = [key for key in changed if key not in current and (
|
||||
re.fullmatch(r'dev[0-9]+', key) or (re.fullmatch(r'mp[0-9]+', key) and before[key].startswith('/')))]
|
||||
unsupported = [key for key in changed if key not in new_keys and key not in storage and key not in detached
|
||||
and key != 'net0'
|
||||
and key not in transaction.ADOPTABLE and key not in transaction.KEPT_AS_IS]
|
||||
if unsupported:
|
||||
raise ValueError(f"{translate('These manual changes cannot be adopted safely:')} {', '.join(unsupported)}")
|
||||
if not new_keys and environment is None:
|
||||
if not member and not (new_keys or storage or detached or custom_changed or environment is not None
|
||||
or {'net0', 'hostname', 'entrypoint'} & set(changed)):
|
||||
return None
|
||||
candidate = copy.deepcopy(record)
|
||||
deployment = candidate['deployment']
|
||||
if custom:
|
||||
deployment['custom_lxc'] = custom
|
||||
else:
|
||||
deployment.pop('custom_lxc', None)
|
||||
details = _adopt_environment(deployment, *environment) if environment else []
|
||||
for key in storage:
|
||||
_storage_change(key, current[key], deployment, record['vmid'])
|
||||
# A host directory or a device taken away in Proxmox holds no data of the container.
|
||||
for key in detached:
|
||||
fields = dict(part.split('=', 1) for part in before[key].split(',') if '=' in part)
|
||||
if key.startswith('mp'):
|
||||
deployment['mounts'] = [mount for mount in deployment.get('mounts', [])
|
||||
if mount.get('container_path') != fields.get('mp')]
|
||||
else:
|
||||
path = fields.get('path') or before[key].split(',', 1)[0]
|
||||
deployment['devices'] = [device for device in deployment.get('devices', [])
|
||||
if device.get('host_path') != path]
|
||||
for key in new_keys:
|
||||
if key.startswith('mp'):
|
||||
mount = _mount(key, current[key], record['vmid'])
|
||||
@@ -164,12 +201,14 @@ def propose(record, config):
|
||||
deployment.setdefault('devices', []).append(device)
|
||||
kind = 'GPU' if gpu_devices.gpu_path(device['host_path']) else 'USB/Coral/NPU'
|
||||
details.append(f"{key}: {device['host_path']} ({kind})")
|
||||
skipped = [*new_keys, *(ENVIRONMENT_KEYS if environment else ())]
|
||||
skipped = [*new_keys, *storage, *detached, *custom_keys, *(ENVIRONMENT_KEYS if environment else ())]
|
||||
filtered = b'\n'.join(line for line in config.splitlines()
|
||||
if not any(line.startswith(key.encode() + b': ') for key in skipped))
|
||||
if environment:
|
||||
if environment or storage or detached or custom_keys:
|
||||
filtered += b''.join(b'\n' + line for line in record['observed']['config'].encode().splitlines()
|
||||
if any(line.startswith(key.encode() + b': ') for key in ENVIRONMENT_KEYS))
|
||||
if any(line.startswith(key.encode() + b': ')
|
||||
for key in (*storage, *detached, *custom_keys,
|
||||
*(ENVIRONMENT_KEYS if environment else ()))))
|
||||
filtered += b'\n'
|
||||
for key, value in transaction.external_changes(record, filtered).items():
|
||||
section, name = transaction.ADOPTABLE[key]
|
||||
@@ -179,31 +218,36 @@ def propose(record, config):
|
||||
candidate['observed']['config_sha256'] = sha(config)
|
||||
candidate['observed']['gpu_devices'] = gpu_devices.capture(config)
|
||||
candidate['observed']['host_bind_sources'] = host_mounts.capture_sources(config)
|
||||
transaction.preflight(candidate, copy.deepcopy(candidate), config)
|
||||
return {'candidate': candidate, 'details': details,
|
||||
transaction.preflight(candidate, copy.deepcopy(candidate), config, coordinated=member)
|
||||
# A disk or a device is reviewed; the rest is what the user already decided.
|
||||
return {'candidate': candidate, 'details': details, 'review': bool(new_keys),
|
||||
'previous_record_hash': sha(instances.location(instances.ROOT, record['vmid']).read_bytes()),
|
||||
'config_sha256': sha(config)}
|
||||
|
||||
|
||||
def commit(root, vmid, proposal):
|
||||
with instances.locked(root):
|
||||
record = instances.read(root, vmid)
|
||||
path = instances.location(root, vmid)
|
||||
if sha(path.read_bytes()) != proposal['previous_record_hash']:
|
||||
raise ValueError(translate('The OCI contract changed while reviewing; nothing was adopted'))
|
||||
config = instances.command('pct', 'config', str(vmid))
|
||||
if sha(config) != proposal['config_sha256']:
|
||||
raise ValueError(translate('The LXC changed while reviewing; nothing was adopted'))
|
||||
checked = propose(record, config)
|
||||
if checked is None or checked['details'] != proposal['details']:
|
||||
raise ValueError(translate('The proposed changes no longer match; nothing was adopted'))
|
||||
candidate = checked['candidate']
|
||||
observed = instances.observe(vmid, record['installation_id'], record['observed']['archive_path'],
|
||||
record['observed']['resolved_registry_digest'], record['observed']['image'])
|
||||
if observed['config_sha256'] != proposal['config_sha256']:
|
||||
raise ValueError(translate('The LXC changed during verification; nothing was adopted'))
|
||||
candidate['observed'] = observed
|
||||
history = path.parent / 'history'
|
||||
instances.write(history / f'before-reconciliation-{uuid.uuid4().hex}.json', record)
|
||||
instances.write(path, candidate)
|
||||
return candidate
|
||||
return commit_locked(root, vmid, proposal)
|
||||
|
||||
|
||||
def commit_locked(root, vmid, proposal, member=False):
|
||||
record = instances.read(root, vmid)
|
||||
path = instances.location(root, vmid)
|
||||
if sha(path.read_bytes()) != proposal['previous_record_hash']:
|
||||
raise ValueError(translate('The OCI contract changed while reviewing; nothing was adopted'))
|
||||
config = instances.command('pct', 'config', str(vmid))
|
||||
if sha(config) != proposal['config_sha256']:
|
||||
raise ValueError(translate('The LXC changed while reviewing; nothing was adopted'))
|
||||
checked = propose(record, config, member)
|
||||
if checked is None or checked['details'] != proposal['details']:
|
||||
raise ValueError(translate('The proposed changes no longer match; nothing was adopted'))
|
||||
candidate = checked['candidate']
|
||||
observed = instances.observe(vmid, record['installation_id'], record['observed']['archive_path'],
|
||||
record['observed']['resolved_registry_digest'], record['observed']['image'])
|
||||
if observed['config_sha256'] != proposal['config_sha256']:
|
||||
raise ValueError(translate('The LXC changed during verification; nothing was adopted'))
|
||||
candidate['observed'] = observed
|
||||
history = path.parent / 'history'
|
||||
instances.write(history / f'before-reconciliation-{uuid.uuid4().hex}.json', record)
|
||||
instances.write(path, candidate)
|
||||
return candidate
|
||||
|
||||
@@ -27,7 +27,7 @@ import uuid
|
||||
from urllib.parse import unquote
|
||||
|
||||
import oci_instances as instances
|
||||
from oci_installation_state import image_from_archive, parse_config, private_directory, sha
|
||||
from oci_installation_state import container_curl, image_from_archive, parse_config, private_directory, sha
|
||||
from verify_oci_archive import verify_archive
|
||||
import oci_host_mounts as host_mounts
|
||||
import oci_nested_mounts as nested_mounts
|
||||
@@ -484,11 +484,78 @@ def candidate_contract(record, operation, proposal=None):
|
||||
|
||||
|
||||
# Proxmox settings the rebuilt container gets back exactly as they are: the
|
||||
# LAN leg ProxMenux adds to a suite or stack member, the start order and tags.
|
||||
KEPT_AS_IS = ('net1', 'startup', 'tags')
|
||||
# LAN leg ProxMenux adds to a suite or stack member, the start order, the tags
|
||||
# and what the user set there. ProxMenux does not decide any of them.
|
||||
KEPT_AS_IS = ('net1', 'startup', 'tags', 'features', 'nameserver', 'searchdomain', 'timezone',
|
||||
'cpulimit', 'cmode', 'console', 'tty', 'hookscript', 'protection')
|
||||
ADOPTABLE = {'memory': ('resources', 'memory_mb'), 'swap': ('resources', 'swap_mb'),
|
||||
'cores': ('resources', 'cores'), 'cpulimit': ('resources', 'cores'),
|
||||
'cpuunits': ('resources', 'cpu_units'), 'onboot': (None, 'onboot')}
|
||||
'cpuunits': ('resources', 'cpu_units'), 'onboot': (None, 'onboot'),
|
||||
'hostname': (None, 'hostname'), 'entrypoint': (None, 'custom_entrypoint'),
|
||||
'bridge': ('network', 'bridge'), 'ipv4': ('network', 'ipv4'),
|
||||
'gateway': ('network', 'gateway'), 'firewall': ('network', 'firewall'),
|
||||
'vlan_tag': ('network', 'vlan_tag')}
|
||||
|
||||
|
||||
def network_fields(net):
|
||||
"""What a network line says that the record of an application keeps."""
|
||||
fields = dict(item.split('=', 1) for item in (net or '').split(',') if '=' in item)
|
||||
tag = fields.get('tag')
|
||||
return {'bridge': fields.get('bridge'), 'ipv4': fields.get('ip'), 'gateway': fields.get('gw'),
|
||||
'firewall': fields.get('firewall', '0') == '1', 'vlan_tag': int(tag) if tag else None}
|
||||
|
||||
|
||||
# What the engine writes itself among the lxc.* lines of a container.
|
||||
ENGINE_LXC = ('lxc.environment', 'lxc.init.', 'lxc.signal.', 'lxc.console.', 'lxc.hook.', 'lxc.include',
|
||||
'lxc.idmap', 'lxc.mount.auto')
|
||||
|
||||
|
||||
def without_backup_snapshot(config):
|
||||
"""The configuration without the mark of a snapshot a backup left behind:
|
||||
it is not a setting, and a snapshot of the user is not this one."""
|
||||
return b''.join(line for line in config.splitlines(keepends=True) if line.strip() != b'parent: vzdump')
|
||||
|
||||
|
||||
def drop_backup_snapshot(vmid):
|
||||
"""A disk with a snapshot cannot be moved. The one an interrupted backup
|
||||
left goes the way Proxmox removes it before its next backup."""
|
||||
config = run('pct', 'config', str(vmid))
|
||||
if without_backup_snapshot(config) != config and 'lock' not in parse_config(config):
|
||||
run('pct', 'delsnapshot', str(vmid), 'vzdump', '--force', '1')
|
||||
|
||||
|
||||
def settings(config):
|
||||
"""The settings of a configuration to compare with those saved: without
|
||||
that mark, and without the lines that follow the mount points."""
|
||||
return parse_config(nested_mounts.without(without_backup_snapshot(config)))
|
||||
|
||||
|
||||
def custom_lxc(record, config):
|
||||
"""The raw lxc.* lines the user added in Proxmox: the ones saved before that
|
||||
are still there, and the new ones. A rebuilt container gets them back."""
|
||||
before = record['observed']['config'].splitlines()
|
||||
saved = record['deployment'].get('custom_lxc', [])
|
||||
lines = config.decode().splitlines()
|
||||
return [line for line in lines if line.startswith('lxc.') and ': ' in line
|
||||
and not line.startswith(ENGINE_LXC) and not nested_mounts.own(line)
|
||||
and (line in saved or line not in before)]
|
||||
|
||||
|
||||
def kept_settings(record, candidate, cfg):
|
||||
"""What a rebuilt container takes from the one it replaces. The network and
|
||||
the features go back as they are unless Modify has just changed them.
|
||||
Protection comes back at the end: a protected container cannot be replaced."""
|
||||
kept = {key: cfg[key] for key in KEPT_AS_IS if key in cfg and key != 'protection'}
|
||||
if 'cores' not in cfg:
|
||||
kept.pop('cpulimit', None)
|
||||
if candidate['deployment'].get('security') != record['deployment'].get('security'):
|
||||
kept.pop('features', None)
|
||||
if 'net0' in cfg and candidate['deployment'].get('network') == record['deployment'].get('network'):
|
||||
kept['net0'] = cfg['net0']
|
||||
# Only a command the user set: the one of the image changes with the image.
|
||||
if candidate['deployment'].get('custom_entrypoint') and 'entrypoint' in cfg:
|
||||
kept['entrypoint'] = cfg['entrypoint']
|
||||
return kept
|
||||
|
||||
|
||||
def external_changes(record, config, adopt=True):
|
||||
@@ -496,7 +563,7 @@ def external_changes(record, config, adopt=True):
|
||||
values. Any change the new container cannot keep is refused."""
|
||||
if sha(config) == record['observed']['config_sha256']:
|
||||
return {}
|
||||
before, now = parse_config(record['observed']['config'].encode()), parse_config(config)
|
||||
before, now = settings(record['observed']['config'].encode()), settings(config)
|
||||
changed = sorted(key for key in before.keys() | now.keys() if before.get(key) != now.get(key))
|
||||
if ('description' in changed
|
||||
and instances.identity(record['observed']['config'].encode()) == record['installation_id']
|
||||
@@ -504,10 +571,19 @@ def external_changes(record, config, adopt=True):
|
||||
changed.remove('description')
|
||||
cores_key = 'cpulimit' if 'cpulimit' in before and 'cores' not in before else 'cores'
|
||||
values = {}
|
||||
# The network line stays as the user left it: a stack carries it over as
|
||||
# it is, and an application also takes it into its record.
|
||||
if 'net0' in changed:
|
||||
changed.remove('net0')
|
||||
if adopt:
|
||||
old_net, new_net = network_fields(before.get('net0')), network_fields(now.get('net0'))
|
||||
values.update({key: value for key, value in new_net.items() if old_net[key] != value})
|
||||
for key in changed if adopt else ():
|
||||
value = now.get(key)
|
||||
if key == 'onboot':
|
||||
values[key] = value == '1'
|
||||
elif key in ('hostname', 'entrypoint') and value:
|
||||
values[key] = value
|
||||
elif key == 'cpuunits' and value is None:
|
||||
values[key] = None
|
||||
elif key in ('memory', 'swap', 'cpuunits', cores_key) and value and re.fullmatch(r'[0-9]+', value):
|
||||
@@ -540,6 +616,10 @@ def preflight(record, candidate, config, coordinated=None, in_place=False, basel
|
||||
target = desired.setdefault(section, {}) if section else desired
|
||||
if target.get(name) == recorded.get(name):
|
||||
target[name] = value
|
||||
# Lines the user added are carried over and are not part of what is checked.
|
||||
custom = deployment.get('custom_lxc', [])
|
||||
config = b''.join(line for line in without_backup_snapshot(config).splitlines(keepends=True)
|
||||
if line.decode().rstrip('\n') not in custom)
|
||||
cfg = parse_config(config)
|
||||
for key in ('lxc.init.uid', 'lxc.init.gid'):
|
||||
if key in cfg and not re.fullmatch(r'[0-9]+', cfg[key]):
|
||||
@@ -818,7 +898,7 @@ def healthcheck(vmid, template):
|
||||
ok = False
|
||||
if ip:
|
||||
any_status = check.get('accept_any_status', False)
|
||||
args = ['curl', '-sS' if any_status else '-fsS', '--noproxy', '*', '-o', '/dev/null',
|
||||
args = [*container_curl(vmid, run), '-sS' if any_status else '-fsS', '--noproxy', '*', '-o', '/dev/null',
|
||||
'--max-time', str(check.get('request_timeout_seconds', 5))]
|
||||
if any_status:
|
||||
args += ['-w', '%{http_code}']
|
||||
@@ -933,6 +1013,8 @@ def commit(root, journal, state):
|
||||
gpu_devices.verify(state.get('desired_gpu_devices', {}))
|
||||
gpu_devices.check(config, state['candidate_contract']['deployment'])
|
||||
checkpoint(journal, state, 'committing')
|
||||
if state.get('protected'):
|
||||
run('pct', 'set', str(state['vmid']), '--protection', '1')
|
||||
result = copy.deepcopy(state['candidate_contract'])
|
||||
result.update(status='installed', completed_at=instances.now(),
|
||||
last_transaction=state['id'],
|
||||
@@ -1087,6 +1169,7 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i
|
||||
if 'stack' in expected:
|
||||
record['stack'] = copy.deepcopy(expected['stack'])
|
||||
candidate = candidate_contract(record, operation, proposal)
|
||||
drop_backup_snapshot(vmid)
|
||||
before = run('pct', 'config', str(vmid))
|
||||
cfg, actual, mac = preflight(record, candidate, before, coordinated)
|
||||
description = original_description({'record': record, 'before_config': before.decode()})
|
||||
@@ -1140,7 +1223,8 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i
|
||||
'external_data_acknowledged': acknowledge_external_data,
|
||||
'original_gpu_devices': original_gpu, 'desired_gpu_devices': desired_gpu,
|
||||
'was_running': run('pct', 'status', str(vmid)).strip() == b'status: running'}
|
||||
state['kept_config'] = {key: cfg[key] for key in KEPT_AS_IS if key in cfg}
|
||||
state['kept_config'] = kept_settings(record, candidate, cfg)
|
||||
state['protected'] = cfg.get('protection') == '1'
|
||||
# The installer adds default tags. Preserve an explicit absence too, so
|
||||
# deleting all tags in Proxmox does not bring them back on the next update.
|
||||
state['kept_config']['tags'] = cfg.get('tags', '')
|
||||
@@ -1150,11 +1234,9 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i
|
||||
state['runtime_deployment']['kept_proxmox_settings'] = state['kept_config']
|
||||
if coordinated:
|
||||
state['coordinated'] = coordinated
|
||||
state['preserved_stack_config'] = {key: cfg[key] for key in ('net1', 'startup', 'hookscript') if key in cfg}
|
||||
state['preserved_stack_config']['tags'] = cfg.get('tags', '')
|
||||
if operation == 'update':
|
||||
state['preserved_stack_config'].update({key: cfg[key] for key in
|
||||
('net0', 'features', 'cmode', 'console', 'tty', 'cpuunits') if key in cfg})
|
||||
state['preserved_stack_config'] = dict(state['kept_config'])
|
||||
if operation == 'update' and 'cpuunits' in cfg:
|
||||
state['preserved_stack_config']['cpuunits'] = cfg['cpuunits']
|
||||
checkpoint(journal, state, 'prepared')
|
||||
pending = copy.deepcopy(record)
|
||||
pending.update(status='updating', pending_transaction=str(journal), transaction_id=state['id'])
|
||||
@@ -1227,6 +1309,8 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i
|
||||
firewall = Path(f'/etc/pve/firewall/{vmid}.fw')
|
||||
state['firewall_config'] = firewall.read_text() if firewall.exists() else None
|
||||
checkpoint(journal, state, 'replacing-root')
|
||||
if state.get('protected'):
|
||||
run('pct', 'set', str(vmid), '--delete', 'protection')
|
||||
run('pct', 'destroy', str(vmid))
|
||||
checkpoint(journal, state, 'installing-candidate')
|
||||
if show:
|
||||
|
||||
@@ -42,8 +42,9 @@ def environment(config):
|
||||
entries.extend(line.split(': ', 1)[1].split('\0'))
|
||||
for entry in entries:
|
||||
name, sep, value = entry.partition('=')
|
||||
if not sep or name in result:
|
||||
if not sep:
|
||||
raise ValueError(translate('Duplicate variable in the contract; review it before editing'))
|
||||
# A variable set twice takes its last value, as it does in LXC.
|
||||
result[name] = value
|
||||
return result
|
||||
|
||||
@@ -129,7 +130,7 @@ def plan(record, candidate, config):
|
||||
values[native] = str(value)
|
||||
if 'network' in changed:
|
||||
network = new['network']
|
||||
if set(network) - {'bridge', 'ipv4', 'gateway', 'firewall', 'host_managed', 'mac_address'}:
|
||||
if set(network) - {'bridge', 'ipv4', 'gateway', 'firewall', 'host_managed', 'mac_address', 'vlan_tag'}:
|
||||
raise ValueError(translate('Unsupported network settings'))
|
||||
if not re.fullmatch(r'[A-Za-z0-9_.:-]+', network['bridge']):
|
||||
raise ValueError(translate('Invalid bridge'))
|
||||
@@ -146,6 +147,11 @@ def plan(record, candidate, config):
|
||||
fields.update(bridge=network['bridge'], ip=address)
|
||||
if gateway: fields['gw'] = gateway
|
||||
else: fields.pop('gw', None)
|
||||
vlan_tag = network.get('vlan_tag')
|
||||
if vlan_tag is not None and (isinstance(vlan_tag, bool) or not isinstance(vlan_tag, int) or not 1 <= vlan_tag <= 4094):
|
||||
raise ValueError(translate('The VLAN tag must be a number from 1 to 4094'))
|
||||
if vlan_tag: fields['tag'] = str(vlan_tag)
|
||||
elif 'vlan_tag' in old['network']: fields.pop('tag', None)
|
||||
for key, native in (('firewall', 'firewall'), ('host_managed', 'host-managed')):
|
||||
if old['network'].get(key) != network.get(key):
|
||||
fields[native] = '1' if network.get(key) else '0'
|
||||
|
||||
@@ -65,6 +65,12 @@ def check(config):
|
||||
raise ValueError(translate('A container mount has a source, backup or permission different from the saved record'))
|
||||
|
||||
|
||||
def stale(config):
|
||||
"""Whether a mount point taken away left its lines behind."""
|
||||
wanted = {line for group in groups(config) for line in group}
|
||||
return any(own(line) and line not in wanted for line in config.decode().splitlines())
|
||||
|
||||
|
||||
def follow(vmid):
|
||||
"""Write the lines of the mount points the container has now, and no
|
||||
others. Any snapshot section is left as it is."""
|
||||
|
||||
@@ -204,12 +204,14 @@ def filter_config(config, deployment, runtime_config=None):
|
||||
declared = set(tmpfs_lines(deployment, runtime_config if runtime_config is not None else config))
|
||||
files = {m['source'] for m in deployment.get('mounts', []) if m.get('type') == 'host-bind'}
|
||||
|
||||
custom = {line.encode() for line in deployment.get('custom_lxc', [])}
|
||||
|
||||
def own(line):
|
||||
entry = line.decode().strip().split(': ', 1)[1]
|
||||
match = FILE_BIND.fullmatch(entry)
|
||||
return entry in declared or bool(match and match.group(1) in files)
|
||||
return b''.join(line for line in config.splitlines(keepends=True)
|
||||
if not line.startswith(b'lxc.include: ') and not (
|
||||
if not line.startswith(b'lxc.include: ') and line.rstrip(b'\n') not in custom and not (
|
||||
line.startswith(b'lxc.mount.entry: ') and own(line)))
|
||||
|
||||
|
||||
|
||||
@@ -183,14 +183,36 @@ def register(root, vmid):
|
||||
if path not in listed and (gpu_devices.gpu_path(path)
|
||||
or gpu_devices.peripheral_path(path))]
|
||||
instances.write(instances.location(root, vmid), record)
|
||||
snapshot(root, vmid)
|
||||
|
||||
|
||||
def adopt(root, vmid):
|
||||
"""Take into the record what was changed in Proxmox on a member, so an
|
||||
operation on the stack keeps it. The caller holds the registry lock."""
|
||||
record = instances.read(root, vmid)
|
||||
config = instances.command('pct', 'config', str(vmid))
|
||||
if instances.same_config_except_notes(record, config, ignore_tags=True):
|
||||
return
|
||||
if (record['deployment'].get('replay_profile') or {}).get('adapter') in CONVERTERS:
|
||||
register(root, vmid)
|
||||
return
|
||||
proposal = reconcile.propose(record, config, member=True)
|
||||
if proposal:
|
||||
reconcile.commit_locked(root, vmid, proposal, member=True)
|
||||
snapshot(root, vmid)
|
||||
|
||||
|
||||
def snapshot(root, vmid):
|
||||
"""Refresh the copy of a member its main container keeps."""
|
||||
record = instances.read(root, vmid)
|
||||
primary_id = (record.get('stack_member') or {}).get('primary_vmid', vmid)
|
||||
primary = instances.read(root, primary_id)
|
||||
snapshot = instances.read(root, vmid)
|
||||
snapshot.pop('stack', None)
|
||||
copied = instances.read(root, vmid)
|
||||
copied.pop('stack', None)
|
||||
members = primary.get('stack', {}).get('members', [])
|
||||
for index, member in enumerate(members):
|
||||
if member.get('vmid') == vmid:
|
||||
members[index] = snapshot
|
||||
members[index] = copied
|
||||
instances.write(instances.location(root, primary_id), primary)
|
||||
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import oci_stack_plan
|
||||
import oci_stack_transaction as stack_tx
|
||||
import oci_stack_replay as replay
|
||||
import oci_work_backup as work_backup
|
||||
from oci_installation_state import image_from_archive, parse_config, private_directory, sha
|
||||
from oci_installation_state import container_curl, image_from_archive, parse_config, private_directory, sha
|
||||
from oci_update_current import resolve_archive
|
||||
from oci_ui import translate, msg_info, msg_ok, msg_warn, msg_error
|
||||
|
||||
@@ -545,9 +545,9 @@ class NativeAdapter:
|
||||
# Main/custom web services retain both contracts: an exec
|
||||
# check never replaces the advertised HTTP readiness probe.
|
||||
if check.get('url') and (self.services[vmid].get('frontend') or check.get('endpoint')):
|
||||
member_tx.run('curl', '-fsS', '--noproxy', '*', '--max-time', '5', check['url'])
|
||||
member_tx.run(*container_curl(vmid, member_tx.run), '-fsS', '--noproxy', '*', '--max-time', '5', check['url'])
|
||||
elif check['type'] == 'http':
|
||||
member_tx.run('curl', '-fsS', '--noproxy', '*', '--max-time', '5', check['url'])
|
||||
member_tx.run(*container_curl(vmid, member_tx.run), '-fsS', '--noproxy', '*', '--max-time', '5', check['url'])
|
||||
elif check['type'] == 'tcp':
|
||||
with socket.create_connection(tcp_target(check), timeout=5):
|
||||
pass
|
||||
@@ -848,6 +848,10 @@ def run(vmid, recover=False, acknowledge_external_data=False, keep_backup=None,
|
||||
result = stack_tx.execute(journal, adapter)
|
||||
msg_ok(translate('Stack recovery completed; every member is back to its previous installation.'))
|
||||
return result
|
||||
import oci_stack_modify
|
||||
for member in primary['stack']['members']:
|
||||
oci_stack_modify.adopt(root, member['vmid'])
|
||||
primary = instances.read(root, primary_id)
|
||||
records, inventory = {}, {}
|
||||
for snapshot in primary['stack']['members']:
|
||||
member_id = snapshot['vmid']
|
||||
|
||||
@@ -507,8 +507,11 @@ def normalize(record):
|
||||
names = set()
|
||||
def add_environment(value):
|
||||
name, equals, content = value.partition('=')
|
||||
if not equals or not re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]*', name) or name in names:
|
||||
if not equals or not re.fullmatch(r'[A-Za-z_][A-Za-z0-9_]*', name):
|
||||
raise ValueError(translate('Ambiguous or invalid environment variable'))
|
||||
# A variable set twice takes its last value, as it does in LXC.
|
||||
if name in names:
|
||||
environment[:] = [item for item in environment if item['name'] != name]
|
||||
names.add(name)
|
||||
environment.append({'name': name, 'value': content})
|
||||
for line in config.splitlines():
|
||||
|
||||
@@ -228,6 +228,29 @@ oci_log_tail() {
|
||||
tail -n "${1:-15}" "$OCI_LOG" | sed "s/^/${_OCI_TAB} /"
|
||||
}
|
||||
|
||||
# The host may have no route to the VLAN of an interface: there curl runs
|
||||
# inside the network of the container.
|
||||
oci_curl() {
|
||||
local vmid=$1
|
||||
shift
|
||||
if grep -Eq '^net[0-9]+:.*,tag=' "/etc/pve/lxc/${vmid}.conf" 2>/dev/null; then
|
||||
nsenter --net="/proc/$(lxc-info -n "$vmid" -pH)/ns/net" curl "$@"
|
||||
else
|
||||
curl "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
# tag= option of an access interface from the given network key of the
|
||||
# deployment, in OCI_VLAN_OPTION. Returns 1 when the tag is not valid.
|
||||
oci_vlan_option() {
|
||||
local tag
|
||||
OCI_VLAN_OPTION=""
|
||||
tag=$(jq -r --arg key "$1" '.network[$key] // empty' "$DEPLOYMENT_FILE")
|
||||
[[ -z $tag ]] && return 0
|
||||
[[ $tag =~ ^[0-9]+$ ]] && (( tag >= 1 && tag <= 4094 )) || return 1
|
||||
OCI_VLAN_OPTION=",tag=${tag}"
|
||||
}
|
||||
|
||||
# ip= and gw= options of an access interface, DHCP or a static IPv4 with an
|
||||
# optional gateway, in OCI_ACCESS_NET. Returns 1 when a value is not valid.
|
||||
oci_access_net() {
|
||||
|
||||
@@ -162,13 +162,16 @@ def kept_settings(changes, deployment):
|
||||
"""Names of the settings changed in Proxmox that the new container keeps."""
|
||||
labels = {'memory': translate('Memory'), 'swap': translate('Swap'), 'cores': translate('CPU cores'),
|
||||
'cpulimit': translate('CPU cores'), 'cpuunits': translate('CPU priority'),
|
||||
'onboot': translate('Start with Proxmox')}
|
||||
'onboot': translate('Start with Proxmox'), 'hostname': translate('Hostname'),
|
||||
'entrypoint': translate('Start command'),
|
||||
'bridge': translate('Network'), 'ipv4': translate('Network'), 'gateway': translate('Network'),
|
||||
'firewall': translate('Network'), 'vlan_tag': translate('VLAN tag')}
|
||||
kept = []
|
||||
for key, value in changes.items():
|
||||
section, name = transaction.ADOPTABLE[key]
|
||||
if (deployment.get(section, {}) if section else deployment).get(name) == value:
|
||||
kept.append(labels[key])
|
||||
return kept
|
||||
return list(dict.fromkeys(kept))
|
||||
|
||||
|
||||
def application_name(record, vmid):
|
||||
|
||||
@@ -85,6 +85,7 @@ def build_suite(template, ui, mode='advanced'):
|
||||
from .custom_mounts import ask_stack_custom_mounts
|
||||
ask_stack_custom_mounts(ui, drafts, storage)
|
||||
bridge = ask_bridge(ui, translate('Access bridge'), 'vmbr0', mode)
|
||||
vlan_tag = access.ask_vlan_tag(quiet)
|
||||
reachable = [app for app in selected if app != 'unpackerr']
|
||||
labels, gateway = access.ask_addresses(ui, bridge, [app.capitalize() for app in reachable])
|
||||
addresses = dict(zip(reachable, labels.values()))
|
||||
@@ -148,5 +149,5 @@ def build_suite(template, ui, mode='advanced'):
|
||||
translate('Gluetun/VPN not yet available: this suite does not route downloads through a VPN.')
|
||||
],
|
||||
'rootfs_storage':storage,'template_storage':cache,'onboot':onboot,'start_after_create':True,
|
||||
'network':{'frontend_bridge':bridge,'frontend_gateway':gateway,'private_allocation':'automatic','private_bridge':'vmbr10',
|
||||
'network':{'frontend_bridge':bridge,'frontend_gateway':gateway,**({'frontend_vlan_tag':vlan_tag} if vlan_tag else {}),'private_allocation':'automatic','private_bridge':'vmbr10',
|
||||
'private_subnet':'10.77.0.0/24','private_host_address':'10.77.0.1/24'}}
|
||||
|
||||
@@ -338,11 +338,13 @@ def _deployment_summary_text(template: dict[str, Any], deployment: dict[str, Any
|
||||
static = [address for address in addresses if address != "dhcp"]
|
||||
row(translate("Network"), f"{network['frontend_bridge']} · {', '.join(static) if static else 'DHCP'}"
|
||||
+ (f" · gw {network['frontend_gateway']}" if network.get("frontend_gateway") else "")
|
||||
+ (f" · VLAN {network['frontend_vlan_tag']}" if network.get("frontend_vlan_tag") else "")
|
||||
+ f" · {translate('private network assigned automatically')}")
|
||||
elif network:
|
||||
ipv4 = network.get("ipv4", "dhcp")
|
||||
row(translate("Network"), f"{network.get('bridge', '-')} · {'DHCP' if ipv4 == 'dhcp' else ipv4}"
|
||||
+ (f" · gw {network['gateway']}" if network.get("gateway") else ""))
|
||||
+ (f" · gw {network['gateway']}" if network.get("gateway") else "")
|
||||
+ (f" · VLAN {network['vlan_tag']}" if network.get("vlan_tag") else ""))
|
||||
devices = plan.get("devices", [])
|
||||
if plan.get("hardware_profile") or devices:
|
||||
profiles = (template.get("proxmox", {}).get("installer_profile", {})
|
||||
|
||||
@@ -589,6 +589,7 @@ def build_deployment(
|
||||
if key not in tmpfs_mounts[-1] and installer_profile.get("volume_owner", {}).get(key) is not None:
|
||||
tmpfs_mounts[-1][key] = installer_profile["volume_owner"][key]
|
||||
|
||||
vlan_tag = None
|
||||
if advanced:
|
||||
bridge = ask_bridge(ui, translate("Network bridge"), defaults["bridge"])
|
||||
if mac_address:
|
||||
@@ -597,6 +598,7 @@ def build_deployment(
|
||||
ipv4, gateway = "host", None
|
||||
else:
|
||||
ipv4, gateway = access.ask_ipv4(ui, bridge)
|
||||
vlan_tag = access.ask_vlan_tag(ui)
|
||||
else:
|
||||
bridge = ask_bridge(ui, "", defaults["bridge"], DEFAULT_MODE)
|
||||
if host_monitor:
|
||||
@@ -760,6 +762,7 @@ def build_deployment(
|
||||
"firewall": defaults["firewall"],
|
||||
"host_managed": defaults["host_managed_network"],
|
||||
"mac_address": mac_address,
|
||||
**({"vlan_tag": vlan_tag} if vlan_tag else {}),
|
||||
},
|
||||
"features": defaults.get("features", []),
|
||||
"security": {
|
||||
@@ -832,6 +835,7 @@ def _build_rclone_deployment(
|
||||
raise InstallError(translate("The web UI password must have at least 24 characters"))
|
||||
bridge = ask_bridge(ui, translate("Network bridge"), schema["bridge"]["default"])
|
||||
ipv4, gateway = access.ask_ipv4(ui, bridge)
|
||||
vlan_tag = access.ask_vlan_tag(ui)
|
||||
|
||||
return {
|
||||
"vmid": int(vmid_text) if vmid_text else None,
|
||||
@@ -851,6 +855,7 @@ def _build_rclone_deployment(
|
||||
"firewall": defaults["firewall"],
|
||||
"host_managed": defaults["host_managed_network"],
|
||||
"mac_address": None,
|
||||
**({"vlan_tag": vlan_tag} if vlan_tag else {}),
|
||||
},
|
||||
"features": defaults["features"],
|
||||
"security": {
|
||||
@@ -1082,6 +1087,7 @@ def _build_immich_deployment(
|
||||
addresses, frontend_gateway = access.ask_addresses(
|
||||
essential_ui(ui), frontend_bridge, [translate("Immich server"), "Immich Machine learning"])
|
||||
server_ipv4, ml_ipv4 = addresses.values()
|
||||
frontend_vlan_tag = access.ask_vlan_tag(ui)
|
||||
timezone = ui.ask(translate("Timezone"), host.timezone())
|
||||
(video_acceleration, render_device, vaapi_driver,
|
||||
ml_acceleration, ml_render) = _ask_immich_acceleration(ui, rootfs_storage)
|
||||
@@ -1112,6 +1118,7 @@ def _build_immich_deployment(
|
||||
"frontend_ipv4": server_ipv4,
|
||||
"machine_learning_frontend_ipv4": ml_ipv4,
|
||||
"frontend_gateway": frontend_gateway,
|
||||
**({"frontend_vlan_tag": frontend_vlan_tag} if frontend_vlan_tag else {}),
|
||||
"private_allocation": "automatic",
|
||||
"private_bridge": defaults["private_network"]["bridge"],
|
||||
"private_subnet": defaults["private_network"]["subnet"],
|
||||
@@ -1199,6 +1206,7 @@ def _build_nextcloud_stack_deployment(
|
||||
extra_mounts = ask_application_extra_paths(ui, ["/var/www/html"], application_storage or rootfs_storage)
|
||||
frontend_bridge = ask_bridge(ui, translate("Access bridge for Nextcloud"), defaults["frontend_network"]["bridge"])
|
||||
addresses, frontend_gateway = access.ask_addresses(essential_ui(ui), frontend_bridge, [""])
|
||||
frontend_vlan_tag = access.ask_vlan_tag(ui)
|
||||
timezone = ui.ask(translate("Timezone"), host.timezone())
|
||||
admin_username = ui.ask(
|
||||
translate("Initial administrator user"), defaults["application"]["admin_username"]
|
||||
@@ -1239,6 +1247,7 @@ def _build_nextcloud_stack_deployment(
|
||||
"frontend_bridge": frontend_bridge,
|
||||
"frontend_ipv4": addresses[""],
|
||||
"frontend_gateway": frontend_gateway,
|
||||
**({"frontend_vlan_tag": frontend_vlan_tag} if frontend_vlan_tag else {}),
|
||||
"private_allocation": "automatic",
|
||||
"private_bridge": private["bridge"],
|
||||
"private_subnet": private["subnet"],
|
||||
@@ -1323,6 +1332,7 @@ def _build_paperless_stack_deployment(
|
||||
"/usr/src/paperless/export"], application_storage)
|
||||
frontend_bridge = ask_bridge(ui, translate("Access bridge for Paperless"), defaults["frontend_network"]["bridge"])
|
||||
addresses, frontend_gateway = access.ask_addresses(essential_ui(ui), frontend_bridge, [""])
|
||||
frontend_vlan_tag = access.ask_vlan_tag(ui)
|
||||
timezone = ui.ask(translate("Timezone"), host.timezone())
|
||||
ocr_language = ui.ask(translate("OCR language (Tesseract code)"), defaults["ocr_language"])
|
||||
if not re.fullmatch(r"[a-z]{3}(?:\+[a-z]{3})*", ocr_language):
|
||||
@@ -1368,6 +1378,7 @@ def _build_paperless_stack_deployment(
|
||||
"frontend_bridge": frontend_bridge,
|
||||
"frontend_ipv4": addresses[""],
|
||||
"frontend_gateway": frontend_gateway,
|
||||
**({"frontend_vlan_tag": frontend_vlan_tag} if frontend_vlan_tag else {}),
|
||||
"private_allocation": "automatic",
|
||||
"private_bridge": private["bridge"],
|
||||
"private_subnet": private["subnet"],
|
||||
@@ -1450,6 +1461,7 @@ def _build_tandoor_stack_deployment(
|
||||
ui, ["/opt/recipes/mediafiles", "/opt/recipes/staticfiles"], static_storage)
|
||||
frontend_bridge = ask_bridge(ui, translate("Access bridge for Tandoor"), defaults["frontend_network"]["bridge"])
|
||||
addresses, frontend_gateway = access.ask_addresses(essential_ui(ui), frontend_bridge, [""])
|
||||
frontend_vlan_tag = access.ask_vlan_tag(ui)
|
||||
timezone = ui.ask(translate("Timezone"), host.timezone())
|
||||
allowed_hosts = ui.ask(
|
||||
translate("Allowed hosts (comma separated; * allows access through the assigned IP)"),
|
||||
@@ -1510,6 +1522,7 @@ def _build_tandoor_stack_deployment(
|
||||
"frontend_bridge": frontend_bridge,
|
||||
"frontend_ipv4": addresses[""],
|
||||
"frontend_gateway": frontend_gateway,
|
||||
**({"frontend_vlan_tag": frontend_vlan_tag} if frontend_vlan_tag else {}),
|
||||
"private_allocation": "automatic",
|
||||
"private_bridge": private["bridge"],
|
||||
"private_subnet": private["subnet"],
|
||||
|
||||
@@ -455,12 +455,11 @@ def manage_instance(project, ui, row, action=None, lifecycle_args=(), heading=No
|
||||
return False
|
||||
if adoption:
|
||||
summary = '\n'.join(adoption['details'])
|
||||
# Variables reordered in Proxmox change nothing that needs a decision.
|
||||
if summary and not ui.review(
|
||||
if adoption['review'] and not ui.review(
|
||||
f"{translate('These changes were made in Proxmox outside ProxMenux:')}\n\n{summary}\n\n"
|
||||
+ translate('They will be saved in oci-compose before continuing.'),
|
||||
translate('Review external OCI changes'),
|
||||
question=translate('Include these changes in oci-compose?'), default=False):
|
||||
question=translate('Include these changes in oci-compose?'), default=True):
|
||||
return False
|
||||
try:
|
||||
record = reconcile.commit(instances.ROOT, row['vmid'], adoption)
|
||||
@@ -694,7 +693,7 @@ def _manage_stack(project, ui, row, action=None, lifecycle_args=()):
|
||||
|
||||
class UnattendedUI:
|
||||
"""The answers of a scheduled run: a step with a positive default goes on,
|
||||
and one that needs a person (adopting external changes) stops the run."""
|
||||
and one that needs a person stops the run."""
|
||||
unattended = True
|
||||
|
||||
def __init__(self):
|
||||
|
||||
@@ -123,6 +123,16 @@ def ask_addresses(ui, bridge: str, labels: list[str], current: dict[str, str] |
|
||||
return {label: str(interface) for label, interface in interfaces.items()}, gateway
|
||||
|
||||
|
||||
def ask_vlan_tag(ui, current: int | None = None) -> int | None:
|
||||
"""The VLAN of the access interface, None when it carries no tag."""
|
||||
text = str(ui.ask(translate("VLAN tag (empty = none)"), str(current or ""), required=False) or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
if not re.fullmatch(r"[0-9]+", text) or not 1 <= int(text) <= 4094:
|
||||
raise ValueError(translate("The VLAN tag must be a number from 1 to 4094"))
|
||||
return int(text)
|
||||
|
||||
|
||||
def ask_ipv4(ui, bridge: str, current: str | None = None,
|
||||
current_gateway: str | None = None) -> tuple[str, str | None]:
|
||||
addresses, gateway = ask_addresses(ui, bridge, [""], {"": current} if current else None, current_gateway)
|
||||
|
||||
@@ -27,7 +27,11 @@ def edit_network(deployment, ui):
|
||||
network = deployment['network']
|
||||
bridge = ask_bridge(ui, translate('Access bridge'), network['bridge'])
|
||||
ipv4, gateway = access.ask_ipv4(ui, bridge, network.get('ipv4'), network.get('gateway'))
|
||||
vlan_tag = access.ask_vlan_tag(ui, network.get('vlan_tag'))
|
||||
network.update(bridge=bridge, ipv4=ipv4, gateway=gateway)
|
||||
network.pop('vlan_tag', None)
|
||||
if vlan_tag:
|
||||
network['vlan_tag'] = vlan_tag
|
||||
|
||||
|
||||
def edit_acceleration(candidate, ui, confirm=True):
|
||||
@@ -119,7 +123,8 @@ def modification_options(candidate):
|
||||
('cores', row(translate('CPU cores'), resources['cores'])),
|
||||
('memory', row(translate('RAM in MiB'), resources['memory_mb'])),
|
||||
('network', row(translate('Network'),
|
||||
f"{network.get('bridge', '-')} · {'DHCP' if address == 'dhcp' else address}")),
|
||||
f"{network.get('bridge', '-')} · {'DHCP' if address == 'dhcp' else address}"
|
||||
+ (f" · VLAN {network['vlan_tag']}" if network.get('vlan_tag') else ''))),
|
||||
# Only the paths added by hand: the application's own volumes are not extra.
|
||||
('paths', row(translate('Extra paths'), sum(1 for m in deployment.get('mounts', []) if m.get('custom')))),
|
||||
]
|
||||
|
||||
@@ -655,6 +655,7 @@ def build_stack(template, ui, mode='advanced'):
|
||||
from .custom_mounts import ask_stack_custom_mounts
|
||||
ask_stack_custom_mounts(ui, drafts, volumes)
|
||||
bridge = ask_bridge(ui, translate('Access bridge'), defaults['bridge'], mode)
|
||||
vlan_tag = access.ask_vlan_tag(quiet)
|
||||
frontend_names = [
|
||||
s['name'] for s in services
|
||||
if (s['name'] == template['compose_stack']['main_service']
|
||||
@@ -765,7 +766,7 @@ def build_stack(template, ui, mode='advanced'):
|
||||
return {'deployment_kind':'generic-multi-lxc-stack','stack_name':name,'base_vmid':int(vmid) if vmid else None,
|
||||
'completion_notes':template.get('proxmox',{}).get('stack_completion_notes',[]),
|
||||
'rootfs_storage':root,'template_storage':cache,'onboot':onboot,'start_after_create':True,
|
||||
'network':{'frontend_bridge':bridge,'frontend_gateway':gateway,'private_allocation':'automatic','private_bridge':'vmbr10','private_subnet':'10.77.0.0/24','private_host_address':'10.77.0.1/24'},
|
||||
'network':{'frontend_bridge':bridge,'frontend_gateway':gateway,**({'frontend_vlan_tag':vlan_tag} if vlan_tag else {}),'private_allocation':'automatic','private_bridge':'vmbr10','private_subnet':'10.77.0.0/24','private_host_address':'10.77.0.1/24'},
|
||||
'services':plans}
|
||||
|
||||
|
||||
|
||||
@@ -67,13 +67,11 @@ class ExternalTagsTests(unittest.TestCase):
|
||||
self.assertEqual(transaction.external_changes(record(), current), {'cores': 4, 'memory': 2048})
|
||||
|
||||
def test_tag_changes_do_not_bypass_identity_or_network_checks(self):
|
||||
for current in (config('homelab').replace(MARKER.encode(), b'22222222-2222-4222-8222-222222222222'),
|
||||
config('homelab').replace(b'bridge=vmbr0', b'bridge=vmbr2')):
|
||||
with self.subTest(config=current):
|
||||
with self.assertRaises(ValueError):
|
||||
reconcile.propose(record(), current)
|
||||
with self.assertRaises(ValueError):
|
||||
transaction.external_changes(record(), current)
|
||||
current = config('homelab').replace(MARKER.encode(), b'22222222-2222-4222-8222-222222222222')
|
||||
with self.assertRaises(ValueError):
|
||||
reconcile.propose(record(), current)
|
||||
with self.assertRaises(ValueError):
|
||||
transaction.external_changes(record(), current + b'arch: arm64\n')
|
||||
|
||||
def test_new_volume_still_requires_adoption_when_tags_also_change(self):
|
||||
saved = record()
|
||||
@@ -90,6 +88,51 @@ class ExternalTagsTests(unittest.TestCase):
|
||||
self.assertEqual(json.loads(path.read_text()), saved)
|
||||
|
||||
|
||||
def test_a_vlan_tag_set_in_proxmox_is_taken_into_the_record(self):
|
||||
for saved_net, current_net, expected in ((b'ip=dhcp\nnet1', b'ip=dhcp,tag=4\nnet1', 4),
|
||||
(b'ip=dhcp,tag=4\nnet1', b'ip=dhcp\nnet1', None)):
|
||||
with self.subTest(expected=expected), tempfile.TemporaryDirectory() as folder:
|
||||
saved = record()
|
||||
before = config('oci;proxmenux').replace(b'ip=dhcp\nnet1', saved_net)
|
||||
saved['observed'].update(config=before.decode(), config_sha256=sha(before))
|
||||
root = Path(folder)
|
||||
path = root / '200/oci-compose.json'
|
||||
path.parent.mkdir()
|
||||
path.write_text(json.dumps(saved))
|
||||
current = before.replace(saved_net, current_net)
|
||||
with patch.object(reconcile.instances, 'ROOT', root):
|
||||
proposal = reconcile.propose(saved, current)
|
||||
self.assertEqual(proposal['candidate']['deployment']['network']['vlan_tag'], expected)
|
||||
self.assertEqual(proposal['details'], [])
|
||||
|
||||
def test_a_host_directory_taken_away_in_proxmox_leaves_the_record(self):
|
||||
saved = record()
|
||||
before = config('oci;proxmenux') + b'mp0: /srv/share,mp=/share,backup=0\n'
|
||||
saved['observed'].update(config=before.decode(), config_sha256=sha(before))
|
||||
saved['deployment']['mounts'] = [{'type': 'host-bind', 'container_path': '/share', 'source': '/srv/share'}]
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
root = Path(folder)
|
||||
path = root / '200/oci-compose.json'
|
||||
path.parent.mkdir()
|
||||
path.write_text(json.dumps(saved))
|
||||
with patch.object(reconcile.instances, 'ROOT', root):
|
||||
proposal = reconcile.propose(saved, config('oci;proxmenux'))
|
||||
self.assertEqual(proposal['candidate']['deployment']['mounts'], [])
|
||||
self.assertFalse(proposal['review'])
|
||||
|
||||
def test_a_data_volume_taken_away_in_proxmox_still_stops_the_update(self):
|
||||
saved = record()
|
||||
before = config('oci;proxmenux') + b'mp0: local-lvm:vm-200-disk-1,mp=/data,size=8G,backup=1\n'
|
||||
saved['observed'].update(config=before.decode(), config_sha256=sha(before))
|
||||
with self.assertRaisesRegex(ValueError, 'mp0'):
|
||||
reconcile.propose(saved, config('oci;proxmenux'))
|
||||
|
||||
def test_a_network_changed_in_proxmox_is_taken_into_the_record(self):
|
||||
current = config('oci;proxmenux').replace(b'bridge=vmbr0', b'bridge=vmbr9')
|
||||
self.assertEqual(transaction.external_changes(record(), current), {'bridge': 'vmbr9'})
|
||||
self.assertEqual(transaction.external_changes(record(), current, adopt=False), {})
|
||||
|
||||
|
||||
class Prepared(Exception):
|
||||
"""Stop a mocked transaction before it can stop or modify any container."""
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ class GuessedEndpointCheckTests(unittest.TestCase):
|
||||
fake = Path(directory) / 'curl'
|
||||
fake.write_text(f'#!/bin/sh\nprintf %s {code}\nexit {exit_code}\n')
|
||||
fake.chmod(0o755)
|
||||
script = (probe_function() + '\nCURL_ARGS=()\nHC_URL=http://192.0.2.1:3306/\n'
|
||||
script = (probe_function() + '\noci_curl() { shift; curl "$@"; }\nCURL_ARGS=()\nHC_URL=http://192.0.2.1:3306/\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"]}'}
|
||||
return subprocess.run(['bash', '-c', script], env=env, capture_output=True).returncode
|
||||
|
||||
@@ -78,7 +78,7 @@ class ProposalTests(unittest.TestCase):
|
||||
self.assertIsNone(reconcile.propose(record, updated))
|
||||
self.assertEqual(reconcile.transaction.external_changes(record, updated), {})
|
||||
|
||||
def test_notes_cannot_hide_identity_or_network_changes(self):
|
||||
def test_notes_cannot_hide_identity_or_platform_changes(self):
|
||||
marker = '11111111-1111-1111-1111-111111111111'
|
||||
config = f'description: Old notes proxmenux-instance={marker}\n'.encode()
|
||||
record = {
|
||||
@@ -88,7 +88,7 @@ class ProposalTests(unittest.TestCase):
|
||||
}
|
||||
with self.assertRaises(ValueError):
|
||||
reconcile.propose(record, b'description: Different instance\n')
|
||||
changed = f'description: New notes proxmenux-instance={marker}\nnet0: name=eth0,bridge=vmbr1\n'.encode()
|
||||
changed = f'description: New notes proxmenux-instance={marker}\narch: arm64\n'.encode()
|
||||
with self.assertRaises(ValueError):
|
||||
reconcile.propose(record, changed)
|
||||
with self.assertRaises(ValueError):
|
||||
@@ -197,10 +197,11 @@ class EnvironmentAdoptionTests(unittest.TestCase):
|
||||
proposal = self.propose(record, 'APP_PASSWORD=first-secret\0PATH=/bin')
|
||||
self.assertEqual(proposal['details'], [])
|
||||
|
||||
def test_removing_an_image_default_is_refused(self):
|
||||
def test_an_image_default_removed_in_proxmox_comes_back_with_its_value(self):
|
||||
record = self.record('PATH=/bin\0APP_PASSWORD=first-secret')
|
||||
with self.assertRaisesRegex(ValueError, 'env'):
|
||||
self.propose(record, 'APP_PASSWORD=first-secret')
|
||||
proposal = self.propose(record, 'APP_PASSWORD=first-secret')
|
||||
self.assertFalse(proposal['review'])
|
||||
self.assertNotIn('PATH', [item['name'] for item in proposal['candidate']['deployment']['environment']])
|
||||
|
||||
def test_removing_a_saved_variable_removes_it_from_the_contract(self):
|
||||
record = self.record('PATH=/bin\0APP_PASSWORD=first-secret')
|
||||
@@ -211,9 +212,9 @@ class EnvironmentAdoptionTests(unittest.TestCase):
|
||||
def test_other_changes_next_to_the_variables_are_still_refused(self):
|
||||
record = self.record('PATH=/bin')
|
||||
config = (f'description: proxmenux-instance={self.marker}\nenv: PATH=/bin\0A=1\n'
|
||||
'net0: name=eth0,bridge=vmbr1\n').encode()
|
||||
'arch: arm64\n').encode()
|
||||
with (patch.object(reconcile.instances, 'identity', return_value=self.marker),
|
||||
self.assertRaisesRegex(ValueError, 'net0')):
|
||||
self.assertRaisesRegex(ValueError, 'arch')):
|
||||
reconcile.propose(record, config)
|
||||
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ class NativeExecWebReadinessTests(unittest.TestCase):
|
||||
adapter.healthcheck(4800)
|
||||
else:
|
||||
adapter.healthcheck(4800)
|
||||
self.assertEqual(probes, ['pct', 'curl'])
|
||||
self.assertEqual(probes, ['pct', 'pct', 'curl'])
|
||||
finally:
|
||||
server.shutdown(); server.server_close(); thread.join(timeout=2)
|
||||
|
||||
|
||||
@@ -61,6 +61,24 @@ class NativePlanTests(unittest.TestCase):
|
||||
self.assertEqual(fields['tag'], '20'); self.assertEqual(fields['ip6'], 'auto')
|
||||
self.assertEqual(fields['ip'], '192.0.2.2/24'); self.assertEqual(fields['gw'], '192.0.2.1')
|
||||
|
||||
def test_the_vlan_tag_is_set_changed_and_removed(self):
|
||||
for saved, wanted, expected in ((None, 30, '30'), (20, 30, '30'), (20, None, None)):
|
||||
with self.subTest(saved=saved, wanted=wanted):
|
||||
self.record['deployment']['network'].pop('vlan_tag', None)
|
||||
self.candidate['deployment']['network'].pop('vlan_tag', None)
|
||||
if saved: self.record['deployment']['network']['vlan_tag'] = saved
|
||||
if wanted: self.candidate['deployment']['network']['vlan_tag'] = wanted
|
||||
self.candidate['deployment']['network']['ipv4'] = '192.0.2.2/24'
|
||||
with patch.object(Path, 'is_dir', return_value=True):
|
||||
result = native.plan(self.record, self.candidate, self.config)
|
||||
fields = dict(x.split('=',1) for x in result['values']['net0'].split(','))
|
||||
self.assertEqual(fields.get('tag'), expected)
|
||||
|
||||
def test_an_invalid_vlan_tag_is_rejected_before_stopping(self):
|
||||
self.candidate['deployment']['network']['vlan_tag'] = 5000
|
||||
with patch.object(Path, 'is_dir', return_value=True), self.assertRaises(ValueError):
|
||||
native.plan(self.record, self.candidate, self.config)
|
||||
|
||||
def test_invalid_network_is_rejected_before_stopping(self):
|
||||
for value in ('nonsense', '2001:db8::1/64'):
|
||||
self.candidate['deployment']['network']['ipv4'] = value
|
||||
|
||||
@@ -131,7 +131,7 @@ class SlovakEditorTests(unittest.TestCase):
|
||||
self.assertIn('Vlastná cesta nemôže skryť systémové adresáre', self.text(ui))
|
||||
|
||||
def test_network_gateway_explains_outbound_route(self):
|
||||
ui = EditorUI(['static'], ['vmbr0', '192.0.2.42/24', ''], confirms=[])
|
||||
ui = EditorUI(['static'], ['vmbr0', '192.0.2.42/24', '', ''], confirms=[])
|
||||
with patch.object(installer.host, 'bridges', return_value=[]), \
|
||||
patch.object(installer.host, 'address_answers', return_value=False):
|
||||
recreation.edit_setting(copy.deepcopy(self.original), ui, 'network')
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
"""The VLAN tag of the access interface is a setting of the installation."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "src"))
|
||||
sys.path.insert(0, str(ROOT / "remote"))
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
|
||||
import oci_installation_state as state
|
||||
import oci_instance_transaction as transaction
|
||||
from proxmenux_oci import network
|
||||
|
||||
|
||||
class Answer:
|
||||
def __init__(self, value):
|
||||
self.value = value
|
||||
|
||||
def ask(self, text, default=None, required=True):
|
||||
return self.value if self.value is not None else default
|
||||
|
||||
|
||||
def record(net0):
|
||||
config = f"arch: amd64\nmemory: 1024\nnet0: {net0}\n"
|
||||
return {"observed": {"config": config, "config_sha256": transaction.sha(config.encode())},
|
||||
"installation_id": "x"}, config.encode()
|
||||
|
||||
|
||||
class Question(unittest.TestCase):
|
||||
def test_empty_means_no_tag_and_the_current_one_is_offered(self):
|
||||
self.assertIsNone(network.ask_vlan_tag(Answer("")))
|
||||
self.assertEqual(network.ask_vlan_tag(Answer(None), 40), 40)
|
||||
self.assertEqual(network.ask_vlan_tag(Answer(" 4 ")), 4)
|
||||
|
||||
def test_only_a_valid_vlan_is_accepted(self):
|
||||
for value in ("0", "4095", "abc", "1,2", "-1"):
|
||||
with self.subTest(value=value), self.assertRaises(ValueError):
|
||||
network.ask_vlan_tag(Answer(value))
|
||||
|
||||
|
||||
class TagSetInProxmox(unittest.TestCase):
|
||||
NET = "name=eth0,bridge=vmbr0,firewall=1,hwaddr=BC:24:11:98:91:D2,ip=dhcp,type=veth"
|
||||
|
||||
def changes(self, before, after, adopt=True):
|
||||
saved, _ = record(before)
|
||||
return transaction.external_changes(saved, record(after)[1], adopt=adopt)
|
||||
|
||||
def test_an_application_takes_the_tag_into_its_record(self):
|
||||
self.assertEqual(self.changes(self.NET, self.NET + ",tag=4"), {"vlan_tag": 4})
|
||||
|
||||
def test_a_tag_removed_in_proxmox_is_taken_as_well(self):
|
||||
self.assertEqual(self.changes(self.NET + ",tag=4", self.NET), {"vlan_tag": None})
|
||||
|
||||
def test_a_stack_member_keeps_the_line_as_it_is(self):
|
||||
self.assertEqual(self.changes(self.NET, self.NET + ",tag=4", adopt=False), {})
|
||||
|
||||
def test_the_rest_of_the_network_line_is_taken_as_well(self):
|
||||
self.assertEqual(self.changes(self.NET, self.NET.replace("vmbr0", "vmbr1") + ",tag=4"),
|
||||
{"bridge": "vmbr1", "vlan_tag": 4})
|
||||
|
||||
|
||||
class Kept(unittest.TestCase):
|
||||
def test_the_update_names_the_tag_it_keeps(self):
|
||||
import oci_update_current as update
|
||||
with patch.object(update, "translate", side_effect=str):
|
||||
self.assertEqual(update.kept_settings({"vlan_tag": 4}, {"network": {"vlan_tag": 4}}), ["VLAN tag"])
|
||||
|
||||
|
||||
class Carried(unittest.TestCase):
|
||||
CONFIG = b"memory: 512\nnameserver: 9.9.9.9\nnet0: name=eth0,mtu=1400,bridge=vmbr0\nprotection: 1\nfeatures: keyctl=1\n"
|
||||
|
||||
def kept(self, candidate_network=None):
|
||||
saved = {"deployment": {"network": {"bridge": "vmbr0"}, "security": {}}}
|
||||
new = {"deployment": {"network": candidate_network or {"bridge": "vmbr0"}, "security": {}}}
|
||||
return transaction.kept_settings(saved, new, state.parse_config(self.CONFIG))
|
||||
|
||||
def test_what_the_user_set_goes_back_to_the_rebuilt_container(self):
|
||||
kept = self.kept()
|
||||
self.assertEqual(kept["nameserver"], "9.9.9.9")
|
||||
self.assertEqual(kept["features"], "keyctl=1")
|
||||
self.assertEqual(kept["net0"], "name=eth0,mtu=1400,bridge=vmbr0")
|
||||
|
||||
def test_protection_comes_back_only_at_the_end(self):
|
||||
self.assertNotIn("protection", self.kept())
|
||||
|
||||
def test_only_a_command_of_the_user_goes_back(self):
|
||||
config = state.parse_config(b"entrypoint: /usr/bin/app --flag\n")
|
||||
same = {"deployment": {}}
|
||||
self.assertNotIn("entrypoint", transaction.kept_settings(same, same, config))
|
||||
custom = {"deployment": {"custom_entrypoint": "/usr/bin/app --flag"}}
|
||||
self.assertEqual(transaction.kept_settings(custom, custom, config)["entrypoint"], "/usr/bin/app --flag")
|
||||
|
||||
def test_a_network_edited_in_modify_is_not_overwritten(self):
|
||||
self.assertNotIn("net0", self.kept({"bridge": "vmbr1"}))
|
||||
|
||||
def test_a_start_command_set_in_proxmox_is_taken(self):
|
||||
saved, config = record("name=eth0,bridge=vmbr0")
|
||||
self.assertEqual(transaction.external_changes(saved, config + b"entrypoint: /bin/app\n"),
|
||||
{"entrypoint": "/bin/app"})
|
||||
|
||||
def test_the_snapshot_a_backup_leaves_is_not_a_change(self):
|
||||
saved, config = record("name=eth0,bridge=vmbr0")
|
||||
self.assertEqual(transaction.external_changes(saved, config + b"parent: vzdump\n"), {})
|
||||
|
||||
def test_a_snapshot_of_the_user_is_not_discarded(self):
|
||||
saved, config = record("name=eth0,bridge=vmbr0")
|
||||
with self.assertRaises(ValueError):
|
||||
transaction.external_changes(saved, config + b"parent: before-upgrade\n")
|
||||
|
||||
def test_raw_lines_of_the_user_are_told_from_those_of_the_engine(self):
|
||||
before = "lxc.init.cwd: /\nlxc.cgroup2.devices.allow: c 1:1 rwm\n"
|
||||
saved = {"observed": {"config": before}, "deployment": {"custom_lxc": ["lxc.cgroup2.devices.allow: c 1:1 rwm"]}}
|
||||
config = (before + "lxc.mount.entry: /a b none bind 0 0\nlxc.environment.runtime: A=1\n").encode()
|
||||
self.assertEqual(transaction.custom_lxc(saved, config),
|
||||
["lxc.cgroup2.devices.allow: c 1:1 rwm", "lxc.mount.entry: /a b none bind 0 0"])
|
||||
|
||||
|
||||
class Probe(unittest.TestCase):
|
||||
def curl(self, net):
|
||||
def run(*args):
|
||||
return b"1234\n" if args[0] == "lxc-info" else f"memory: 512\n{net}\n".encode()
|
||||
return state.container_curl(108, run)
|
||||
|
||||
def test_the_host_looks_at_an_untagged_container(self):
|
||||
self.assertEqual(self.curl("net0: name=eth0,bridge=vmbr0,ip=dhcp"), ["curl"])
|
||||
|
||||
def test_a_tagged_interface_is_looked_at_from_inside(self):
|
||||
expected = ["nsenter", "--net=/proc/1234/ns/net", "curl"]
|
||||
self.assertEqual(self.curl("net0: name=eth0,bridge=vmbr0,tag=4,ip=dhcp"), expected)
|
||||
self.assertEqual(self.curl("net1: name=lan,bridge=vmbr0,tag=4"), expected)
|
||||
|
||||
def test_every_web_check_goes_through_it(self):
|
||||
for name in ("install_oci.sh", "install_paperless_stack.sh", "install_tandoor_stack.sh",
|
||||
"install_nextcloud_stack.sh"):
|
||||
self.assertIn("oci_curl", (ROOT / "remote" / name).read_text(), name)
|
||||
for name in ("oci_instance_transaction.py", "oci_stack_native.py", "install_generic_stack.py",
|
||||
"haos_healthcheck.py"):
|
||||
self.assertIn("container_curl", (ROOT / "remote" / name).read_text(), name)
|
||||
|
||||
|
||||
class Creation(unittest.TestCase):
|
||||
def option(self, network_values):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
deployment = Path(directory) / "deployment.json"
|
||||
deployment.write_text(json.dumps({"network": network_values}))
|
||||
text = (ROOT / "remote/oci_ui.sh").read_text()
|
||||
start = text.index("oci_vlan_option() {")
|
||||
function = text[start:text.index("\n}", start) + 2]
|
||||
return subprocess.run(
|
||||
["/bin/bash", "-c", f'DEPLOYMENT_FILE={deployment}\n{function}\n'
|
||||
'oci_vlan_option vlan_tag; code=$?; echo "$code:$OCI_VLAN_OPTION"'],
|
||||
capture_output=True, text=True, check=True).stdout.strip()
|
||||
|
||||
def test_the_option_of_the_interface(self):
|
||||
self.assertEqual(self.option({"vlan_tag": 4}), "0:,tag=4")
|
||||
self.assertEqual(self.option({}), "0:")
|
||||
|
||||
def test_an_invalid_tag_stops_the_installation(self):
|
||||
self.assertEqual(self.option({"vlan_tag": 5000}), "1:")
|
||||
self.assertEqual(self.option({"vlan_tag": "4,trunks=1"}), "1:")
|
||||
|
||||
def test_every_installer_applies_it(self):
|
||||
for name in ("install_oci.sh", "install_immich_stack.sh", "install_paperless_stack.sh",
|
||||
"install_nextcloud_stack.sh", "install_tandoor_stack.sh"):
|
||||
self.assertIn("oci_vlan_option", (ROOT / "remote" / name).read_text(), name)
|
||||
self.assertIn("frontend_vlan_tag", (ROOT / "remote/install_generic_stack.py").read_text())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user