Merge remote-tracking branch 'origin/develop' into HEAD

# Conflicts:
#	.github/workflows/test-oci-registries.yml
This commit is contained in:
MacRimi
2026-10-17 21:00:18 +02:00
58 changed files with 3257 additions and 290 deletions
+29 -9
View File
@@ -359,13 +359,18 @@ cleanup_canonical_inputs() {
# A derived check accepts any HTTP answer below 500: the application is up,
# even when its first page is a redirect or asks for a login.
healthcheck_probe() {
local code status=0
if [[ ${HC_ANY_STATUS:-false} == true ]]; then
local code
code=$(curl "${CURL_ARGS[@]}" "$HC_URL" 2>/dev/null) || return 1
[[ $code =~ ^[1-4][0-9][0-9]$ ]]
code=$(curl "${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" 2>/dev/null
curl "${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
# in another protocol (HTTP/0.9, weird or empty reply, reset) means it is up.
[[ ${HC_ACCEPT_NON_HTTP:-false} == true ]] \
&& (( status == 1 || status == 8 || status == 52 || status == 56 ))
}
cleanup_failed_install() {
@@ -1025,15 +1030,18 @@ apply_extra_hosts() {
}
# Settings an update gives back to the rebuilt container before it starts:
# the LAN leg of a suite or stack member and its start order.
# the LAN leg of a suite or stack member, its start order and current tags.
apply_kept_settings() {
local key value
while IFS=$'\t' read -r key value; do
[[ -n $key ]] || continue
[[ $key == net1 || $key == startup ]] \
[[ $key == net1 || $key == startup || $key == tags ]] \
|| die "$(translate "Unsupported kept setting:") $key"
oci_quiet pct set "$VMID" "--$key" "$value" \
|| die "$(translate "Could not restore the container setting:") $key"
if [[ $key == tags && -z $value ]]; then
oci_quiet pct set "$VMID" --delete tags
else
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")
}
@@ -1091,7 +1099,7 @@ ensure_runtime_home() {
passwd_file="${rootfs}/etc/passwd"
mount_ct_rootfs
if ! home=$(python3 "$OCI_RUNTIME_RESOLVER" --home "$passwd_file" "$uid" 2>>"${OCI_LOG:-/dev/stderr}"); then
oci_quiet pct unmount "$VMID" || true
oci_quiet pct unmount "$VMID" || die "$(translate "Could not unmount the container filesystem")"
return 0
fi
oci_quiet pct unmount "$VMID" || die "$(translate "Could not unmount the container filesystem")"
@@ -1230,6 +1238,17 @@ apply_installer_profile() {
fi
[[ -d ${rootfs}${target} ]] \
|| { oci_log "The volume to prepare does not exist: $target"; failed=1; break; }
# A reused volume may intentionally be mounted read-only (for example,
# /run/secrets). Do not remove files or change ownership/mode on it.
# Use the most specific containing mount: an RW child can override RO.
if [[ $(jq -r --arg target "$target" '
[.mounts[]? | select(.container_path as $path |
$target == $path or ($target | startswith($path + "/")))]
| sort_by(.container_path | length) | last | .read_only // false' \
"$DEPLOYMENT_FILE") == true ]]; then
oci_log "Skipping preparation of read-only volume: $target"
continue
fi
if [[ $remove_lost_found == true ]]; then
rm -rf "${rootfs}${target}/lost+found" || { failed=1; break; }
fi
@@ -2253,6 +2272,7 @@ if [[ $START_AFTER == 1 && $HAS_STARTUP_HEALTHCHECK == 1 ]]; then
[[ -n $IP ]] || die "$(translate "The healthcheck cannot run without an IP address")"
HC_URL="${HC_SCHEME}://${IP}:${HC_PORT}${HC_PATH}"
HC_ANY_STATUS=$(jq -r '.accept_any_status // false' <<<"$STARTUP_HEALTHCHECK")
HC_ACCEPT_NON_HTTP=$(jq -r '.accept_non_http // false' <<<"$STARTUP_HEALTHCHECK")
if [[ $HC_ANY_STATUS == true ]]; then
CURL_ARGS=(-sS --noproxy '*' -o /dev/null -w '%{http_code}' --max-time "$HC_REQUEST_TIMEOUT")
else
+39 -13
View File
@@ -352,6 +352,22 @@ def profile_keys(deployment):
return keys
GUESSED_ENDPOINT = 'compose-first-tcp-port-fallback'
# curl exit codes for a port that accepted the connection and answered in a
# protocol other than HTTP: HTTP/0.9 refused, weird reply, empty reply, reset.
NON_HTTP_ANSWER = (1, 8, 52, 56)
def probe_http(args):
"""Run one curl probe and return its output and exit code."""
log('$ ' + shlex.join(str(arg) for arg in args))
try:
result = subprocess.run(args, capture_output=True, timeout=60, close_fds=True)
except subprocess.TimeoutExpired:
return b'', -1
return result.stdout, result.returncode
def effective_healthcheck(template):
"""The template's own health check or, when it has none, one derived from
its web address: any HTTP answer below 500 means the application is up."""
@@ -367,9 +383,14 @@ def effective_healthcheck(template):
path = str(endpoint.get('path') or '/')
if (endpoint.get('scheme') in ('http', 'https') and str(endpoint.get('port', '')).isdigit()
and path.startswith('/') and not any(c.isspace() for c in path)):
return {'scheme': endpoint['scheme'], 'port': int(endpoint['port']), 'path': path,
'timeout_seconds': 300, 'request_timeout_seconds': 10, 'stability_seconds': 4,
'verify_tls': False, 'accept_any_status': True}
check = {'scheme': endpoint['scheme'], 'port': int(endpoint['port']), 'path': path,
'timeout_seconds': 300, 'request_timeout_seconds': 10, 'stability_seconds': 4,
'verify_tls': False, 'accept_any_status': True}
# A port taken from the compose file because the recipe names no
# web address may serve a database or SSH: any answer counts.
if endpoint.get('source') == GUESSED_ENDPOINT:
check['accept_non_http'] = True
return check
# Without a web address, the new image must at least keep the container running.
return {'type': 'running', 'timeout_seconds': 90, 'stability_seconds': 20}
@@ -415,10 +436,9 @@ def candidate_contract(record, operation, proposal=None):
return with_default_healthcheck(result)
# Resource settings edited in Proxmox that the new container keeps.
# Proxmox settings the rebuilt container gets back exactly as they are: the
# LAN leg ProxMenux adds to a suite or stack member, and the start order.
KEPT_AS_IS = ('net1', 'startup')
# LAN leg ProxMenux adds to a suite or stack member, the start order and tags.
KEPT_AS_IS = ('net1', 'startup', 'tags')
ADOPTABLE = {'memory': ('resources', 'memory_mb'), 'swap': ('resources', 'swap_mb'),
'cores': ('resources', 'cores'), 'cpulimit': ('resources', 'cores'),
'cpuunits': ('resources', 'cpu_units'), 'onboot': (None, 'onboot')}
@@ -734,11 +754,10 @@ def healthcheck(vmid, template):
args += ['-w', '%{http_code}']
if not check.get('verify_tls', False):
args.append('-k')
try:
answer = run(*args, f'{scheme}://{ip}:{port}{path}')
ok = not any_status or re.fullmatch(rb'[1-4][0-9][0-9]', answer.strip()) is not None
except RuntimeError:
pass
answer, status = probe_http([*args, f'{scheme}://{ip}:{port}{path}'])
ok = status == 0 and (not any_status or re.fullmatch(rb'[1-4][0-9][0-9]', answer.strip()) is not None)
if not ok and check.get('accept_non_http') and status in NON_HTTP_ANSWER:
ok = True
if ok:
stable_since = stable_since or time.monotonic()
if time.monotonic() - stable_since >= stability:
@@ -1051,6 +1070,9 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i
'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}
# 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', '')
if not coordinated and state['kept_config']:
# Applied by the installer before the new container starts, so the
# application has its LAN leg from the first second.
@@ -1058,9 +1080,10 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i
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', 'tags') if key in cfg})
('net0', 'features', 'cmode', 'console', 'tty', 'cpuunits') if key in cfg})
checkpoint(journal, state, 'prepared')
pending = copy.deepcopy(record)
pending.update(status='updating', pending_transaction=str(journal), transaction_id=state['id'])
@@ -1141,7 +1164,10 @@ def apply(root, vmid, archive, operation, proposal=None, registry_digest=None, i
restore_firewall(vmid, state)
if coordinated:
for key, value in state['preserved_stack_config'].items():
run('pct', 'set', str(vmid), '--' + key, value)
if key == 'tags' and not value:
run('pct', 'set', str(vmid), '--delete', 'tags')
else:
run('pct', 'set', str(vmid), '--' + key, value)
check_runtime_mounts(run('pct', 'config', str(vmid)), candidate['deployment'])
gpu_devices.check(run('pct', 'config', str(vmid)), candidate['deployment'])
state['staged_config_sha256'] = sha(owned(vmid, record['installation_id']))
+5 -3
View File
@@ -230,8 +230,9 @@ def identity(config):
return match.group(1) if match else None
def same_config_except_notes(record, config):
"""Accept a presentation-only note edit, never a changed OCI identity or LXC setting."""
def same_config_except_notes(record, config, ignore_tags=False):
"""Accept a presentation-only note edit, never a changed OCI identity or LXC setting.
With `ignore_tags`, Proxmox tags may differ too: the operation keeps them as they are."""
previous = record['observed']['config'].encode()
expected = record['installation_id']
if identity(previous) != expected or identity(config) != expected:
@@ -242,7 +243,8 @@ def same_config_except_notes(record, config):
notes = [line for line in lines if line.startswith(b'description: ')]
if len(notes) != 1:
return None
return b''.join(line for line in lines if not line.startswith(b'description: '))
dropped = (b'description: ', b'tags: ') if ignore_tags else (b'description: ',)
return b''.join(line for line in lines if not line.startswith(dropped))
original = without_notes(previous)
return original is not None and original == without_notes(config)
+32 -5
View File
@@ -4,7 +4,9 @@
from __future__ import annotations
import json
import os
import shlex
import stat
import sys
import tarfile
from pathlib import Path
@@ -90,14 +92,39 @@ def effective_entrypoint(
def home_directory(passwd_path: Path, uid: int) -> str | None:
"""Return the safe home directory recorded for a container UID."""
for line in passwd_path.read_text(encoding="utf-8").splitlines():
"""Read a regular image passwd file; only an absent UID defaults to /."""
if uid < 0:
return None
# The caller supplies <mounted rootfs>/etc/passwd. Pin etc so an image
# symlink or a rename during lookup cannot redirect the host-side read.
directory = os.open(passwd_path.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
try:
if not stat.S_ISREG(os.stat(passwd_path.name, dir_fd=directory, follow_symlinks=False).st_mode):
return None
descriptor = os.open(
passwd_path.name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=directory,
)
try:
# Recheck the opened object, not its name; a FIFO replacement
# must neither block open() nor reach a read operation.
if not stat.S_ISREG(os.fstat(descriptor).st_mode):
return None
with os.fdopen(descriptor, encoding="utf-8", closefd=False) as stream:
content = stream.read()
finally:
os.close(descriptor)
finally:
os.close(directory)
for line in content.splitlines():
fields = line.split(":")
if len(fields) < 7 or fields[2] != str(uid):
if len(fields) < 3 or fields[2] != str(uid):
continue
if len(fields) != 7:
return None
home = fields[5]
return home if home.startswith("/") else None
return None
safe = home.startswith("/") and not any(ord(char) < 32 or ord(char) == 127 for char in home)
return home if safe else None
return "/"
def main() -> int:
+1 -1
View File
@@ -192,7 +192,7 @@ class NativeAdapter:
config = instances.command('pct', 'config', str(vmid))
if instances.identity(config) != record['installation_id']:
raise ValueError(translate('The identity of a member was replaced'))
if (not self.journal.exists() or not self.state().get('replacement_intent')) and not instances.same_config_except_notes(record, config):
if (not self.journal.exists() or not self.state().get('replacement_intent')) and not instances.same_config_except_notes(record, config, ignore_tags=True):
raise ValueError(translate('A member configuration changed during the preparation'))
else:
if Path('/etc/pve/lxc/%s.conf' % vmid).exists():