1478 lines
57 KiB
Python
1478 lines
57 KiB
Python
"""Fixed privileged lifecycle for the managed runtime deployment."""
|
|
|
|
from dataclasses import dataclass
|
|
import json
|
|
import os
|
|
import re
|
|
import signal
|
|
import stat
|
|
import subprocess
|
|
import threading
|
|
import time
|
|
|
|
from host_agent_apply import _new_stopped_runtime_proof
|
|
from host_agent_state import HostOperationState, HostStateError
|
|
|
|
|
|
DOCKER = '/usr/bin/docker'
|
|
PROJECT = 'truf-docker'
|
|
PROJECT_DIRECTORY = '/opt/truf'
|
|
COMPOSE_FILES = ('/opt/truf/compose.yaml', '/opt/truf/compose.edge.yaml')
|
|
DEPLOYMENT_PROFILE_FILE = '/etc/truf/deployment-profile'
|
|
STANDALONE_PROFILE_NAME = 'standalone-edge-v1'
|
|
SHARED_HOST_PROFILE_NAME = 'shared-host-edge-v1'
|
|
EDGE_ENV_FILE = '/etc/truf-edge/edge.env'
|
|
RUNTIME_IMAGE = 'truf-local:runtime'
|
|
EDGE_IMAGE = 'truf-local:edge'
|
|
RUNTIME_SERVICE = 'runtime'
|
|
EDGE_SERVICE = 'edge'
|
|
|
|
COMMAND_OUTPUT_LIMIT = 16 * 1024
|
|
INSPECT_TIMEOUT = 15.0
|
|
EDGE_STOP_TIMEOUT = 45.0
|
|
RUNTIME_STOP_TIMEOUT = 660.0
|
|
REMOVE_TIMEOUT = 45.0
|
|
RECREATE_TIMEOUT = 120.0
|
|
RUNTIME_HEALTH_TIMEOUT = 240.0
|
|
HEALTH_COMMAND_TIMEOUT = 30.0
|
|
EDGE_START_TIMEOUT = 45.0
|
|
EDGE_STABILITY_SECONDS = 10.0
|
|
EDGE_VERIFY_TIMEOUT = 100.0
|
|
POLL_SECONDS = 2.0
|
|
|
|
_HEX_ID = re.compile(r'[0-9a-f]{64}')
|
|
_IMAGE_ID = re.compile(r'sha256:[0-9a-f]{64}')
|
|
_RUNTIME_ENTRYPOINT = (
|
|
'/usr/bin/tini', '--', '/usr/local/bin/python3', '-u', '-I', '-S', '-B',
|
|
'/opt/truf/app/container_runtime.py',
|
|
)
|
|
_RUNTIME_HEALTH_TEST = (
|
|
'CMD', '/usr/local/bin/python3', '-I', '-S', '-B',
|
|
'/opt/truf/app/container_runtime.py', 'health', '--config',
|
|
'/data/config/config.yaml',
|
|
)
|
|
_RUNTIME_TMPFS = {
|
|
'/run/truf': 'rw,nosuid,nodev,noexec,size=64m,mode=0700,uid=10001,gid=10001',
|
|
'/tmp': 'rw,nosuid,nodev,noexec,size=128m,mode=1777',
|
|
}
|
|
_EDGE_TMPFS = {
|
|
'/tmp': 'rw,nosuid,nodev,noexec,size=16m,mode=1777',
|
|
'/run': 'rw,nosuid,nodev,noexec,size=4m,mode=0700,uid=10001,gid=10001',
|
|
}
|
|
_INSPECT_FORMAT = (
|
|
'{"id":{{json .Id}},"image":{{json .Image}},'
|
|
'"status":{{json .State.Status}},"running":{{json .State.Running}},'
|
|
'"paused":{{json .State.Paused}},'
|
|
'"restarting":{{json .State.Restarting}},"dead":{{json .State.Dead}},'
|
|
'"pid":{{json .State.Pid}},"exit_code":{{json .State.ExitCode}},'
|
|
'"oom_killed":{{json .State.OOMKilled}},'
|
|
'"restarts":{{json .RestartCount}},"user":{{json .Config.User}},'
|
|
'"entrypoint":{{json .Config.Entrypoint}},'
|
|
'"command":{{json .Config.Cmd}},'
|
|
'"stop_timeout":{{json .Config.StopTimeout}},'
|
|
'"stop_signal":{{with index .Config "StopSignal"}}{{json .}}'
|
|
'{{else}}""{{end}},'
|
|
'"mounts":"{{range .Mounts}}{{.Type}}|{{if eq .Type "volume"}}'
|
|
'{{.Name}}{{end}}|{{.Source}}|'
|
|
'{{.Destination}}|{{.RW}};{{end}}",'
|
|
'"readonly":{{json .HostConfig.ReadonlyRootfs}},'
|
|
'"privileged":{{json .HostConfig.Privileged}},'
|
|
'"network":{{json .HostConfig.NetworkMode}},'
|
|
'"pid_mode":{{json .HostConfig.PidMode}},'
|
|
'"ipc_mode":{{json .HostConfig.IpcMode}},'
|
|
'"userns_mode":{{json .HostConfig.UsernsMode}},'
|
|
'"cgroupns_mode":{{json .HostConfig.CgroupnsMode}},'
|
|
'"uts_mode":{{json .HostConfig.UTSMode}},'
|
|
'"group_add":{{json .HostConfig.GroupAdd}},'
|
|
'"oci_runtime":{{json .HostConfig.Runtime}},'
|
|
'"devices":{{if .HostConfig.Devices}}{{len .HostConfig.Devices}}'
|
|
'{{else}}0{{end}},'
|
|
'"device_requests":{{if .HostConfig.DeviceRequests}}'
|
|
'{{len .HostConfig.DeviceRequests}}{{else}}0{{end}},'
|
|
'"device_cgroup_rules":{{if .HostConfig.DeviceCgroupRules}}'
|
|
'{{len .HostConfig.DeviceCgroupRules}}{{else}}0{{end}},'
|
|
'"ports":{{json .HostConfig.PortBindings}},'
|
|
'"tmpfs":{{json .HostConfig.Tmpfs}},'
|
|
'"cpus":{{json .HostConfig.NanoCpus}},'
|
|
'"memory":{{json .HostConfig.Memory}},'
|
|
'"pids_limit":{{json .HostConfig.PidsLimit}},'
|
|
'"shm_size":{{json .HostConfig.ShmSize}},'
|
|
'"log_config":{{json .HostConfig.LogConfig}},'
|
|
'"cap_drop":{{json .HostConfig.CapDrop}},'
|
|
'"cap_add":{{json .HostConfig.CapAdd}},'
|
|
'"security_opt":{{json .HostConfig.SecurityOpt}},'
|
|
'"restart_policy":{{json .HostConfig.RestartPolicy}},'
|
|
'"project":{{json (index .Config.Labels "com.docker.compose.project")}},'
|
|
'"service":{{json (index .Config.Labels "com.docker.compose.service")}},'
|
|
'"oneoff":{{json (index .Config.Labels "com.docker.compose.oneoff")}},'
|
|
'"config_hash":{{json (index .Config.Labels '
|
|
'"com.docker.compose.config-hash")}},'
|
|
'"config_files":{{json (index .Config.Labels '
|
|
'"com.docker.compose.project.config_files")}},'
|
|
'"working_dir":{{json (index .Config.Labels '
|
|
'"com.docker.compose.project.working_dir")}},'
|
|
'"health_test":{{with index .Config "Healthcheck"}}{{json .Test}}'
|
|
'{{else}}null{{end}},'
|
|
'"health":{{with index .State "Health"}}{{json .Status}}'
|
|
'{{else}}null{{end}}}'
|
|
)
|
|
_INSPECT_KEYS = {
|
|
'id', 'image', 'status', 'running', 'paused', 'restarting', 'dead',
|
|
'pid', 'exit_code', 'oom_killed', 'restarts', 'user', 'entrypoint',
|
|
'command', 'stop_timeout', 'stop_signal', 'mounts', 'readonly',
|
|
'privileged', 'network', 'pid_mode', 'ipc_mode', 'userns_mode',
|
|
'cgroupns_mode', 'uts_mode', 'group_add', 'oci_runtime', 'devices',
|
|
'device_requests', 'device_cgroup_rules', 'ports', 'tmpfs', 'cpus',
|
|
'memory', 'pids_limit', 'shm_size', 'log_config',
|
|
'cap_drop', 'cap_add', 'security_opt', 'restart_policy', 'project',
|
|
'service', 'oneoff', 'config_hash', 'config_files', 'working_dir',
|
|
'health_test', 'health',
|
|
}
|
|
|
|
|
|
class HostLifecycleError(RuntimeError):
|
|
def __init__(self, category):
|
|
super().__init__('host runtime lifecycle failed')
|
|
self.category = str(category)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class _DeploymentProfile:
|
|
name: str
|
|
compose_files: tuple
|
|
runtime_network: str
|
|
runtime_ports: dict
|
|
runtime_data_volume: str
|
|
runtime_cpus: int
|
|
runtime_memory: int
|
|
edge_cap_add: tuple
|
|
edge_caddyfile: str
|
|
|
|
|
|
STANDALONE_PROFILE = _DeploymentProfile(
|
|
name=STANDALONE_PROFILE_NAME,
|
|
compose_files=COMPOSE_FILES,
|
|
runtime_network=f'{PROJECT}_default',
|
|
runtime_ports={'443/tcp': [{'HostIp': '', 'HostPort': '443'}]},
|
|
runtime_data_volume='truf-docker_data',
|
|
runtime_cpus=2_000_000_000,
|
|
runtime_memory=6 * 1024 ** 3,
|
|
edge_cap_add=('NET_BIND_SERVICE',),
|
|
edge_caddyfile='/etc/caddy/Caddyfile',
|
|
)
|
|
SHARED_HOST_PROFILE = _DeploymentProfile(
|
|
name=SHARED_HOST_PROFILE_NAME,
|
|
compose_files=(
|
|
'/opt/truf/compose.yaml', '/opt/truf/compose.shared-host.yaml',
|
|
),
|
|
runtime_network='host',
|
|
runtime_ports={},
|
|
runtime_data_volume='truf-remote-server-data',
|
|
runtime_cpus=900_000_000,
|
|
runtime_memory=720 * 1024 ** 2,
|
|
edge_cap_add=(),
|
|
edge_caddyfile='/etc/caddy/Caddyfile.shared-host',
|
|
)
|
|
|
|
|
|
def _compose_command(profile):
|
|
return (
|
|
DOCKER, 'compose', '--ansi', 'never', '--project-name', PROJECT,
|
|
'--env-file', EDGE_ENV_FILE, '--project-directory', PROJECT_DIRECTORY,
|
|
*(item for path in profile.compose_files for item in ('--file', path)),
|
|
)
|
|
|
|
|
|
_COMPOSE = _compose_command(STANDALONE_PROFILE)
|
|
_CONFIG_FILES_LABEL = ','.join(COMPOSE_FILES)
|
|
|
|
|
|
def _deployment_profile(path=DEPLOYMENT_PROFILE_FILE):
|
|
descriptor = None
|
|
try:
|
|
descriptor = os.open(
|
|
path, os.O_RDONLY | getattr(os, 'O_CLOEXEC', 0)
|
|
| getattr(os, 'O_NOFOLLOW', 0),
|
|
)
|
|
before = os.fstat(descriptor)
|
|
if (
|
|
not stat.S_ISREG(before.st_mode) or before.st_uid != 0
|
|
or before.st_gid != 0 or stat.S_IMODE(before.st_mode) != 0o444
|
|
or before.st_nlink != 1 or before.st_size > 64
|
|
):
|
|
raise HostLifecycleError('profile')
|
|
payload = os.read(descriptor, 65)
|
|
after = os.fstat(descriptor)
|
|
if (
|
|
len(payload) > 64 or before.st_dev != after.st_dev
|
|
or before.st_ino != after.st_ino or before.st_mode != after.st_mode
|
|
or before.st_uid != after.st_uid or before.st_gid != after.st_gid
|
|
or before.st_nlink != after.st_nlink or before.st_size != after.st_size
|
|
or before.st_mtime_ns != after.st_mtime_ns
|
|
):
|
|
raise HostLifecycleError('profile')
|
|
except FileNotFoundError:
|
|
return STANDALONE_PROFILE
|
|
except HostLifecycleError:
|
|
raise
|
|
except OSError:
|
|
raise HostLifecycleError('profile') from None
|
|
finally:
|
|
if descriptor is not None:
|
|
os.close(descriptor)
|
|
try:
|
|
name = payload.decode('ascii').strip()
|
|
except UnicodeDecodeError:
|
|
raise HostLifecycleError('profile') from None
|
|
profiles = {
|
|
STANDALONE_PROFILE_NAME: STANDALONE_PROFILE,
|
|
SHARED_HOST_PROFILE_NAME: SHARED_HOST_PROFILE,
|
|
}
|
|
if name not in profiles:
|
|
raise HostLifecycleError('profile')
|
|
return profiles[name]
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class _ContainerState:
|
|
container_id: str
|
|
image_id: str
|
|
status: str
|
|
running: bool
|
|
paused: bool
|
|
restarting: bool
|
|
dead: bool
|
|
pid: int
|
|
exit_code: int
|
|
oom_killed: bool
|
|
restarts: int
|
|
user: str
|
|
entrypoint: object
|
|
command: object
|
|
stop_timeout: int
|
|
stop_signal: str
|
|
mounts: tuple
|
|
readonly: bool
|
|
privileged: bool
|
|
network: str
|
|
pid_mode: str
|
|
ipc_mode: str
|
|
userns_mode: str
|
|
cgroupns_mode: str
|
|
uts_mode: str
|
|
group_add: tuple
|
|
oci_runtime: str
|
|
devices: int
|
|
device_requests: int
|
|
device_cgroup_rules: int
|
|
ports: dict
|
|
tmpfs: dict
|
|
cpus: int
|
|
memory: int
|
|
pids_limit: int
|
|
shm_size: int
|
|
log_config: dict
|
|
cap_drop: tuple
|
|
cap_add: tuple
|
|
security_opt: tuple
|
|
restart_policy: dict
|
|
project: str
|
|
service: str
|
|
oneoff: str
|
|
config_hash: str
|
|
config_files: str
|
|
working_dir: str
|
|
health_test: object
|
|
health: object
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class DeploymentSnapshot:
|
|
runtime: _ContainerState
|
|
edge: _ContainerState
|
|
runtime_image_id: str
|
|
edge_image_id: str
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class _DeploymentAttempt:
|
|
snapshot: DeploymentSnapshot
|
|
token: object
|
|
phase: str = 'preflight'
|
|
mutation_started: bool = False
|
|
edge_stop_issued: bool = False
|
|
runtime_stop_issued: bool = False
|
|
forward_runtime_id: str = None
|
|
forward_edge_id: str = None
|
|
forward_edge_config_hash: str = None
|
|
|
|
|
|
def _subprocess_runner(command, timeout):
|
|
environment = {
|
|
'HOME': '/root',
|
|
'LANG': 'C.UTF-8',
|
|
'LC_ALL': 'C.UTF-8',
|
|
'PATH': '/usr/bin:/bin',
|
|
}
|
|
process = None
|
|
reader = None
|
|
output_fd = None
|
|
output_lock = threading.Lock()
|
|
stop_output = threading.Event()
|
|
captured = bytearray()
|
|
overflow = threading.Event()
|
|
read_failed = threading.Event()
|
|
|
|
def close_output():
|
|
nonlocal output_fd
|
|
with output_lock:
|
|
descriptor = output_fd
|
|
output_fd = None
|
|
if descriptor is not None:
|
|
try:
|
|
os.close(descriptor)
|
|
except OSError:
|
|
pass
|
|
|
|
def terminate():
|
|
if process is None:
|
|
return
|
|
try:
|
|
os.killpg(process.pid, signal.SIGKILL)
|
|
except OSError:
|
|
if process.poll() is None:
|
|
try:
|
|
process.kill()
|
|
except OSError:
|
|
pass
|
|
if process.poll() is None:
|
|
try:
|
|
process.wait(timeout=1)
|
|
except subprocess.TimeoutExpired:
|
|
pass
|
|
|
|
def read_output():
|
|
try:
|
|
while not stop_output.is_set():
|
|
chunk = os.read(output_fd, 4096)
|
|
if not chunk:
|
|
return
|
|
if len(captured) <= COMMAND_OUTPUT_LIMIT:
|
|
captured.extend(chunk[:COMMAND_OUTPUT_LIMIT + 1 - len(captured)])
|
|
if len(captured) > COMMAND_OUTPUT_LIMIT:
|
|
overflow.set()
|
|
terminate()
|
|
except Exception:
|
|
if not stop_output.is_set():
|
|
read_failed.set()
|
|
terminate()
|
|
finally:
|
|
close_output()
|
|
|
|
try:
|
|
process = subprocess.Popen(
|
|
command,
|
|
stdin=subprocess.DEVNULL,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.DEVNULL,
|
|
cwd='/',
|
|
env=environment,
|
|
close_fds=True,
|
|
start_new_session=True,
|
|
)
|
|
output_fd = os.dup(process.stdout.fileno())
|
|
process.stdout.close()
|
|
reader = threading.Thread(target=read_output, daemon=True)
|
|
reader.start()
|
|
try:
|
|
return_code = process.wait(timeout=max(0.001, float(timeout)))
|
|
except subprocess.TimeoutExpired:
|
|
terminate()
|
|
raise HostLifecycleError('timeout') from None
|
|
reader.join(timeout=1)
|
|
if (
|
|
reader.is_alive()
|
|
or read_failed.is_set()
|
|
or overflow.is_set()
|
|
or return_code != 0
|
|
):
|
|
terminate()
|
|
raise HostLifecycleError('command')
|
|
return bytes(captured)
|
|
except HostLifecycleError:
|
|
raise
|
|
except Exception:
|
|
terminate()
|
|
raise HostLifecycleError('command') from None
|
|
finally:
|
|
if process is not None and (
|
|
process.poll() is None or (reader is not None and reader.is_alive())
|
|
):
|
|
terminate()
|
|
if reader is not None and reader.is_alive():
|
|
stop_output.set()
|
|
close_output()
|
|
reader.join(timeout=1)
|
|
close_output()
|
|
if (
|
|
process is not None and process.stdout is not None
|
|
and not process.stdout.closed
|
|
):
|
|
process.stdout.close()
|
|
|
|
|
|
def _json_payload(payload):
|
|
try:
|
|
if not payload or len(payload) > COMMAND_OUTPUT_LIMIT:
|
|
raise ValueError('bounded JSON required')
|
|
return json.loads(payload.decode('utf-8'))
|
|
except (UnicodeDecodeError, ValueError, TypeError):
|
|
raise HostLifecycleError('evidence') from None
|
|
|
|
|
|
def _string_tuple(value):
|
|
if value is None:
|
|
return ()
|
|
if not isinstance(value, list) or any(type(item) is not str for item in value):
|
|
raise HostLifecycleError('evidence')
|
|
return tuple(value)
|
|
|
|
|
|
def _optional_string_tuple(value):
|
|
return None if value is None else _string_tuple(value)
|
|
|
|
|
|
def _mount_tuple(value):
|
|
if type(value) is not str or len(value) > 4096 or not value.endswith(';'):
|
|
raise HostLifecycleError('evidence')
|
|
mounts = []
|
|
entries = value.split(';')
|
|
if entries[-1] != '' or len(entries) > 17:
|
|
raise HostLifecycleError('evidence')
|
|
for entry in entries[:-1]:
|
|
fields = entry.split('|')
|
|
if len(fields) != 5 or fields[4] not in ('true', 'false'):
|
|
raise HostLifecycleError('evidence')
|
|
mount_type, name, source, destination, writable = fields
|
|
identity = name if mount_type == 'volume' else source
|
|
if not mount_type or not identity or not destination or any(
|
|
character in mount_type + identity + destination for character in '\r\n"'
|
|
):
|
|
raise HostLifecycleError('evidence')
|
|
mounts.append((mount_type, identity, destination, writable == 'true'))
|
|
return tuple(sorted(mounts))
|
|
|
|
|
|
def _string_map(value):
|
|
if value is None:
|
|
return {}
|
|
if (
|
|
not isinstance(value, dict)
|
|
or len(value) > 16
|
|
or any(type(key) is not str or type(item) is not str
|
|
for key, item in value.items())
|
|
):
|
|
raise HostLifecycleError('evidence')
|
|
return dict(value)
|
|
|
|
|
|
def _port_map(value):
|
|
if value is None:
|
|
return {}
|
|
if not isinstance(value, dict) or len(value) > 8:
|
|
raise HostLifecycleError('evidence')
|
|
result = {}
|
|
for port, bindings in value.items():
|
|
if type(port) is not str or not isinstance(bindings, list) or len(bindings) > 8:
|
|
raise HostLifecycleError('evidence')
|
|
normalized = []
|
|
for binding in bindings:
|
|
if (
|
|
not isinstance(binding, dict)
|
|
or set(binding) != {'HostIp', 'HostPort'}
|
|
or type(binding['HostIp']) is not str
|
|
or type(binding['HostPort']) is not str
|
|
):
|
|
raise HostLifecycleError('evidence')
|
|
normalized.append(dict(binding))
|
|
result[port] = normalized
|
|
return result
|
|
|
|
|
|
def _container_state(payload):
|
|
value = _json_payload(payload)
|
|
try:
|
|
if not isinstance(value, dict) or set(value) != _INSPECT_KEYS:
|
|
raise ValueError('inspect shape')
|
|
for name in ('running', 'paused', 'restarting', 'dead', 'oom_killed',
|
|
'readonly', 'privileged'):
|
|
if type(value[name]) is not bool:
|
|
raise ValueError('inspect boolean')
|
|
for name in (
|
|
'pid', 'exit_code', 'restarts', 'stop_timeout', 'devices',
|
|
'device_requests', 'device_cgroup_rules', 'cpus', 'memory',
|
|
'pids_limit', 'shm_size',
|
|
):
|
|
if type(value[name]) is not int or value[name] < 0:
|
|
raise ValueError('inspect integer')
|
|
for name in (
|
|
'id', 'image', 'status', 'user', 'stop_signal', 'network',
|
|
'pid_mode', 'ipc_mode', 'userns_mode', 'cgroupns_mode', 'uts_mode',
|
|
'oci_runtime', 'project', 'service', 'oneoff', 'config_hash',
|
|
'config_files', 'working_dir',
|
|
):
|
|
if type(value[name]) is not str:
|
|
raise ValueError('inspect string')
|
|
if value['health'] is not None and type(value['health']) is not str:
|
|
raise ValueError('inspect health')
|
|
if not isinstance(value['restart_policy'], dict):
|
|
raise ValueError('inspect restart policy')
|
|
if not isinstance(value['log_config'], dict):
|
|
raise ValueError('inspect log config')
|
|
return _ContainerState(
|
|
container_id=value['id'], image_id=value['image'],
|
|
status=value['status'], running=value['running'],
|
|
paused=value['paused'], restarting=value['restarting'],
|
|
dead=value['dead'], pid=value['pid'],
|
|
exit_code=value['exit_code'], oom_killed=value['oom_killed'],
|
|
restarts=value['restarts'], user=value['user'],
|
|
entrypoint=_optional_string_tuple(value['entrypoint']),
|
|
command=_optional_string_tuple(value['command']),
|
|
stop_timeout=value['stop_timeout'], stop_signal=value['stop_signal'],
|
|
mounts=_mount_tuple(value['mounts']),
|
|
readonly=value['readonly'], privileged=value['privileged'],
|
|
network=value['network'], pid_mode=value['pid_mode'],
|
|
ipc_mode=value['ipc_mode'], userns_mode=value['userns_mode'],
|
|
cgroupns_mode=value['cgroupns_mode'], uts_mode=value['uts_mode'],
|
|
group_add=_string_tuple(value['group_add']),
|
|
oci_runtime=value['oci_runtime'], devices=value['devices'],
|
|
device_requests=value['device_requests'],
|
|
device_cgroup_rules=value['device_cgroup_rules'],
|
|
ports=_port_map(value['ports']),
|
|
tmpfs=_string_map(value['tmpfs']), cpus=value['cpus'],
|
|
memory=value['memory'], pids_limit=value['pids_limit'],
|
|
shm_size=value['shm_size'], log_config=dict(value['log_config']),
|
|
cap_drop=_string_tuple(value['cap_drop']),
|
|
cap_add=_string_tuple(value['cap_add']),
|
|
security_opt=_string_tuple(value['security_opt']),
|
|
restart_policy=dict(value['restart_policy']),
|
|
project=value['project'], service=value['service'],
|
|
oneoff=value['oneoff'], config_hash=value['config_hash'],
|
|
config_files=value['config_files'], working_dir=value['working_dir'],
|
|
health_test=_optional_string_tuple(value['health_test']),
|
|
health=value['health'],
|
|
)
|
|
except (KeyError, TypeError, ValueError):
|
|
raise HostLifecycleError('evidence') from None
|
|
|
|
|
|
class FixedDeploymentLifecycle:
|
|
def __init__(self, *, _runner=None, _clock=None, _sleep=None, _profile=None):
|
|
self._runner = _runner or _subprocess_runner
|
|
self._clock = _clock or time.monotonic
|
|
self._sleep = _sleep or time.sleep
|
|
self._profile = _profile or _deployment_profile()
|
|
self._compose_command = _compose_command(self._profile)
|
|
self._token = object()
|
|
|
|
def _run(self, command, timeout):
|
|
try:
|
|
payload = self._runner(tuple(command), float(timeout))
|
|
if not isinstance(payload, bytes) or len(payload) > COMMAND_OUTPUT_LIMIT:
|
|
raise HostLifecycleError('command')
|
|
return payload
|
|
except HostLifecycleError:
|
|
raise
|
|
except Exception:
|
|
raise HostLifecycleError('command') from None
|
|
|
|
def _compose(self, *arguments, timeout=INSPECT_TIMEOUT):
|
|
return self._run((*self._compose_command, *arguments), timeout)
|
|
|
|
def _image_id(self, image):
|
|
payload = self._run(
|
|
(DOCKER, 'image', 'inspect', '--format', '{{json .Id}}', image),
|
|
INSPECT_TIMEOUT,
|
|
)
|
|
value = _json_payload(payload)
|
|
if type(value) is not str or _IMAGE_ID.fullmatch(value) is None:
|
|
raise HostLifecycleError('evidence')
|
|
return value
|
|
|
|
def _resolve(self, service, timeout=INSPECT_TIMEOUT):
|
|
payload = self._compose(
|
|
'ps', '--all', '--quiet', service, timeout=timeout,
|
|
)
|
|
try:
|
|
lines = payload.decode('ascii').splitlines()
|
|
except UnicodeDecodeError:
|
|
raise HostLifecycleError('evidence') from None
|
|
if len(lines) != 1 or _HEX_ID.fullmatch(lines[0]) is None:
|
|
raise HostLifecycleError('evidence')
|
|
return lines[0]
|
|
|
|
def _resolve_optional(self, service, timeout=INSPECT_TIMEOUT):
|
|
payload = self._compose(
|
|
'ps', '--all', '--quiet', service, timeout=timeout,
|
|
)
|
|
try:
|
|
lines = payload.decode('ascii').splitlines()
|
|
except UnicodeDecodeError:
|
|
raise HostLifecycleError('evidence') from None
|
|
if not lines:
|
|
return None
|
|
if len(lines) != 1 or _HEX_ID.fullmatch(lines[0]) is None:
|
|
raise HostLifecycleError('evidence')
|
|
return lines[0]
|
|
|
|
def _inspect(self, container_id, timeout=INSPECT_TIMEOUT):
|
|
if _HEX_ID.fullmatch(container_id) is None:
|
|
raise HostLifecycleError('evidence')
|
|
state = _container_state(self._run(
|
|
(DOCKER, 'container', 'inspect', '--format', _INSPECT_FORMAT,
|
|
container_id),
|
|
timeout,
|
|
))
|
|
if state.container_id != container_id:
|
|
raise HostLifecycleError('identity')
|
|
return state
|
|
|
|
def _require_metadata(
|
|
self, state, service, image_id, runtime_id, *, config_hash=None,
|
|
):
|
|
expected_policy = (
|
|
{'Name': 'on-failure', 'MaximumRetryCount': 3}
|
|
if service == RUNTIME_SERVICE
|
|
else {'Name': 'unless-stopped', 'MaximumRetryCount': 0}
|
|
)
|
|
expected_cap_add = (
|
|
() if service == RUNTIME_SERVICE else self._profile.edge_cap_add
|
|
)
|
|
expected_network = (
|
|
self._profile.runtime_network
|
|
if service == RUNTIME_SERVICE else f'container:{runtime_id}'
|
|
)
|
|
runtime = service == RUNTIME_SERVICE
|
|
expected_mounts = (
|
|
{
|
|
('volume', self._profile.runtime_data_volume, '/data', True),
|
|
('bind', '/etc/truf/runtime', '/data/config', False),
|
|
('bind', '/etc/truf/worker-packages', '/data/worker-packages', False),
|
|
('bind', '/var/lib/truf/runtime-document-candidates', '/data/runtime-document-candidates', True),
|
|
('bind', '/run/truf/host-agent.sock', '/run/truf/host-agent.sock', False),
|
|
('bind', '/var/lib/truf/host-agent/results', '/data/host-agent-results', False),
|
|
('bind', '/run/truf-postgres', '/run/truf-postgres', True),
|
|
} if runtime else {
|
|
('volume', '/data', True), ('volume', '/config', True),
|
|
('bind', '/var/log/caddy', True),
|
|
('bind', '/etc/caddy/denylist', False),
|
|
}
|
|
)
|
|
actual_mounts = (
|
|
set(state.mounts)
|
|
if runtime else {
|
|
(mount_type, destination, writable)
|
|
for mount_type, _identity, destination, writable in state.mounts
|
|
}
|
|
)
|
|
expected_ports = self._profile.runtime_ports if runtime else {}
|
|
expected_log = {
|
|
'Type': 'json-file',
|
|
'Config': {'max-size': '16m' if runtime else '8m', 'max-file': '4'},
|
|
}
|
|
if (
|
|
_HEX_ID.fullmatch(state.container_id) is None
|
|
or state.image_id != image_id
|
|
or _IMAGE_ID.fullmatch(state.image_id) is None
|
|
or state.user != '10001:10001'
|
|
or state.readonly is not True
|
|
or state.privileged is not False
|
|
or state.project != PROJECT
|
|
or state.service != service
|
|
or state.oneoff != 'False'
|
|
or _HEX_ID.fullmatch(state.config_hash) is None
|
|
or (config_hash is not None and state.config_hash != config_hash)
|
|
or state.config_files != ','.join(self._profile.compose_files)
|
|
or state.working_dir != PROJECT_DIRECTORY
|
|
or state.network != expected_network
|
|
or set(state.cap_drop) != {'ALL'}
|
|
or set(state.cap_add) != set(expected_cap_add)
|
|
or set(state.security_opt) != {'no-new-privileges:true'}
|
|
or state.restart_policy != expected_policy
|
|
or state.entrypoint != (
|
|
_RUNTIME_ENTRYPOINT if runtime
|
|
else ('/usr/local/bin/truf-edge-entrypoint',)
|
|
)
|
|
or state.command != (
|
|
('run', '--config', '/data/config/config.yaml') if runtime else None
|
|
)
|
|
or state.health_test != (_RUNTIME_HEALTH_TEST if runtime else None)
|
|
or state.stop_timeout != (600 if runtime else 30)
|
|
or state.stop_signal != ('SIGTERM' if runtime else '')
|
|
or len(state.mounts) != len(expected_mounts)
|
|
or actual_mounts != expected_mounts
|
|
or state.pid_mode != '' or state.ipc_mode != 'private'
|
|
or state.userns_mode != '' or state.cgroupns_mode != 'private'
|
|
or state.uts_mode != '' or state.group_add
|
|
or state.oci_runtime != 'runc' or state.devices != 0
|
|
or state.device_requests != 0 or state.device_cgroup_rules != 0
|
|
or state.ports != expected_ports
|
|
or state.tmpfs != (_RUNTIME_TMPFS if runtime else _EDGE_TMPFS)
|
|
or state.cpus != (
|
|
self._profile.runtime_cpus if runtime else 1_000_000_000
|
|
)
|
|
or state.memory != (
|
|
self._profile.runtime_memory if runtime else 256 * 1024 ** 2
|
|
)
|
|
or state.pids_limit != (512 if runtime else 128)
|
|
or state.shm_size != (256 * 1024 ** 2 if runtime else 64 * 1024 ** 2)
|
|
or state.log_config != expected_log
|
|
):
|
|
raise HostLifecycleError('identity')
|
|
|
|
def _require_running(
|
|
self, state, service, image_id, runtime_id, *, fresh, config_hash=None,
|
|
):
|
|
self._require_metadata(
|
|
state, service, image_id, runtime_id, config_hash=config_hash,
|
|
)
|
|
if (
|
|
state.status != 'running'
|
|
or state.running is not True
|
|
or state.paused or state.restarting or state.dead or state.oom_killed
|
|
or state.pid <= 0
|
|
or (fresh and state.restarts != 0)
|
|
):
|
|
raise HostLifecycleError('health')
|
|
|
|
def _require_stopped(self, current, previous, service, runtime_id):
|
|
self._require_metadata(
|
|
current, service, previous.image_id, runtime_id,
|
|
config_hash=previous.config_hash,
|
|
)
|
|
if (
|
|
current.status != 'exited'
|
|
or current.running or current.paused or current.restarting
|
|
or current.dead or current.oom_killed
|
|
or current.pid != 0 or current.exit_code != 0
|
|
or current.restarts != previous.restarts
|
|
):
|
|
raise HostLifecycleError('stop')
|
|
|
|
def preflight(self):
|
|
self._compose('config', '--quiet')
|
|
runtime_image = self._image_id(RUNTIME_IMAGE)
|
|
edge_image = self._image_id(EDGE_IMAGE)
|
|
runtime_id = self._resolve(RUNTIME_SERVICE)
|
|
edge_id = self._resolve(EDGE_SERVICE)
|
|
runtime = self._inspect(runtime_id)
|
|
edge = self._inspect(edge_id)
|
|
self._require_running(
|
|
runtime, RUNTIME_SERVICE, runtime_image, runtime_id, fresh=False,
|
|
)
|
|
self._require_running(
|
|
edge, EDGE_SERVICE, edge_image, runtime_id, fresh=False,
|
|
)
|
|
return DeploymentSnapshot(runtime, edge, runtime_image, edge_image)
|
|
|
|
def begin_attempt(self, snapshot):
|
|
if not isinstance(snapshot, DeploymentSnapshot):
|
|
raise HostLifecycleError('state')
|
|
return _DeploymentAttempt(snapshot, self._token)
|
|
|
|
def _require_attempt(self, attempt):
|
|
if (
|
|
not isinstance(attempt, _DeploymentAttempt)
|
|
or attempt.token is not self._token
|
|
):
|
|
raise HostLifecycleError('state')
|
|
return attempt
|
|
|
|
def stop_cleanly(self, attempt):
|
|
attempt = self._require_attempt(attempt)
|
|
snapshot = attempt.snapshot
|
|
attempt.mutation_started = True
|
|
attempt.edge_stop_issued = True
|
|
attempt.phase = 'stopping-edge'
|
|
self._compose(
|
|
'stop', '--timeout', '30', EDGE_SERVICE,
|
|
timeout=EDGE_STOP_TIMEOUT,
|
|
)
|
|
stopped_edge = self._inspect(snapshot.edge.container_id)
|
|
self._require_stopped(
|
|
stopped_edge, snapshot.edge, EDGE_SERVICE,
|
|
snapshot.runtime.container_id,
|
|
)
|
|
attempt.runtime_stop_issued = True
|
|
attempt.phase = 'stopping-runtime'
|
|
self._compose(
|
|
'stop', '--timeout', '600', RUNTIME_SERVICE,
|
|
timeout=RUNTIME_STOP_TIMEOUT,
|
|
)
|
|
stopped_runtime = self._inspect(snapshot.runtime.container_id)
|
|
self._require_stopped(
|
|
stopped_runtime, snapshot.runtime, RUNTIME_SERVICE,
|
|
snapshot.runtime.container_id,
|
|
)
|
|
attempt.phase = 'stopped'
|
|
return attempt
|
|
|
|
def authorize_replacement(self, attempt, operation_id):
|
|
attempt = self._require_attempt(attempt)
|
|
if attempt.phase != 'stopped':
|
|
raise HostLifecycleError('state')
|
|
snapshot = attempt.snapshot
|
|
runtime_id = snapshot.runtime.container_id
|
|
if (
|
|
self._resolve(RUNTIME_SERVICE) != runtime_id
|
|
or self._resolve(EDGE_SERVICE) != snapshot.edge.container_id
|
|
):
|
|
raise HostLifecycleError('identity')
|
|
self._require_stopped(
|
|
self._inspect(snapshot.edge.container_id), snapshot.edge,
|
|
EDGE_SERVICE, runtime_id,
|
|
)
|
|
self._require_stopped(
|
|
self._inspect(runtime_id), snapshot.runtime,
|
|
RUNTIME_SERVICE, runtime_id,
|
|
)
|
|
if (
|
|
self._image_id(RUNTIME_IMAGE) != snapshot.runtime_image_id
|
|
or self._image_id(EDGE_IMAGE) != snapshot.edge_image_id
|
|
):
|
|
raise HostLifecycleError('identity')
|
|
return _new_stopped_runtime_proof(operation_id, purpose='forward')
|
|
|
|
def _remaining(self, deadline, maximum):
|
|
remaining = deadline - self._clock()
|
|
if remaining <= 0:
|
|
raise HostLifecycleError('health')
|
|
return min(float(maximum), remaining)
|
|
|
|
def _strict_runtime_health(self, timeout):
|
|
try:
|
|
payload = self._compose(
|
|
'exec', '-T', '--user', '10001:10001', RUNTIME_SERVICE,
|
|
'/usr/local/bin/python3', '-I', '-S', '-B',
|
|
'/opt/truf/app/container_runtime.py', 'health',
|
|
'--config', '/data/config/config.yaml', '--require-worker-api',
|
|
'--require-discovery-producers',
|
|
timeout=min(HEALTH_COMMAND_TIMEOUT, timeout),
|
|
)
|
|
value = _json_payload(payload)
|
|
if (
|
|
not isinstance(value, dict)
|
|
or set(value) != {
|
|
'healthy', 'activation_state', 'postgres', 'workers',
|
|
}
|
|
or value.get('healthy') is not True
|
|
or value.get('activation_state') != 'ACTIVE'
|
|
or value.get('postgres') != 'READY'
|
|
or not isinstance(value.get('workers'), list)
|
|
or any(type(name) is not str for name in value['workers'])
|
|
or not {
|
|
'worker-api', 'result-ingester', 'jsonl-projector',
|
|
}.issubset(value['workers'])
|
|
):
|
|
raise HostLifecycleError('health')
|
|
except HostLifecycleError as error:
|
|
if error.category == 'health':
|
|
raise
|
|
raise HostLifecycleError('health') from None
|
|
|
|
def _wait_runtime(self, runtime_id, image_id, config_hash):
|
|
deadline = self._clock() + RUNTIME_HEALTH_TIMEOUT
|
|
while True:
|
|
if self._resolve(
|
|
RUNTIME_SERVICE, self._remaining(deadline, INSPECT_TIMEOUT),
|
|
) != runtime_id:
|
|
raise HostLifecycleError('identity')
|
|
current = self._inspect(
|
|
runtime_id, self._remaining(deadline, INSPECT_TIMEOUT),
|
|
)
|
|
self._require_running(
|
|
current, RUNTIME_SERVICE, image_id, runtime_id, fresh=True,
|
|
config_hash=config_hash,
|
|
)
|
|
if current.health == 'healthy':
|
|
try:
|
|
self._strict_runtime_health(
|
|
self._remaining(deadline, HEALTH_COMMAND_TIMEOUT),
|
|
)
|
|
except HostLifecycleError as error:
|
|
if error.category != 'health':
|
|
raise
|
|
self._sleep(min(
|
|
POLL_SECONDS,
|
|
self._remaining(deadline, POLL_SECONDS),
|
|
))
|
|
continue
|
|
else:
|
|
return
|
|
if current.health not in ('starting', 'unhealthy'):
|
|
raise HostLifecycleError('health')
|
|
self._sleep(min(POLL_SECONDS, self._remaining(deadline, POLL_SECONDS)))
|
|
|
|
def _wait_edge(self, edge_id, edge_image_id, runtime_id, config_hash):
|
|
deadline = self._clock() + EDGE_VERIFY_TIMEOUT
|
|
running_deadline = self._clock() + EDGE_START_TIMEOUT
|
|
while True:
|
|
if self._resolve(
|
|
EDGE_SERVICE, self._remaining(running_deadline, INSPECT_TIMEOUT),
|
|
) != edge_id:
|
|
raise HostLifecycleError('identity')
|
|
current = self._inspect(
|
|
edge_id, self._remaining(running_deadline, INSPECT_TIMEOUT),
|
|
)
|
|
self._require_metadata(
|
|
current, EDGE_SERVICE, edge_image_id, runtime_id,
|
|
config_hash=config_hash,
|
|
)
|
|
if (
|
|
current.status == 'running'
|
|
and current.running is True
|
|
and current.paused is False
|
|
and current.restarting is False
|
|
and current.dead is False
|
|
and current.oom_killed is False
|
|
and current.pid > 0
|
|
and current.restarts == 0
|
|
):
|
|
break
|
|
if (
|
|
current.restarting or current.dead or current.oom_killed
|
|
or current.status == 'exited'
|
|
):
|
|
raise HostLifecycleError('health')
|
|
self._sleep(min(
|
|
POLL_SECONDS, self._remaining(running_deadline, POLL_SECONDS),
|
|
))
|
|
self._compose(
|
|
'exec', '-T', '--user', '10001:10001', EDGE_SERVICE,
|
|
'caddy', 'validate', '--config', self._profile.edge_caddyfile,
|
|
'--adapter', 'caddyfile',
|
|
timeout=self._remaining(deadline, EDGE_START_TIMEOUT),
|
|
)
|
|
stable_until = self._clock() + EDGE_STABILITY_SECONDS
|
|
if stable_until > deadline:
|
|
raise HostLifecycleError('health')
|
|
while self._clock() < stable_until:
|
|
self._sleep(min(POLL_SECONDS, stable_until - self._clock()))
|
|
if self._resolve(
|
|
EDGE_SERVICE, self._remaining(deadline, INSPECT_TIMEOUT),
|
|
) != edge_id:
|
|
raise HostLifecycleError('identity')
|
|
current = self._inspect(
|
|
edge_id, self._remaining(deadline, INSPECT_TIMEOUT),
|
|
)
|
|
self._require_running(
|
|
current, EDGE_SERVICE, edge_image_id, runtime_id, fresh=True,
|
|
config_hash=config_hash,
|
|
)
|
|
|
|
def recreate_and_verify(self, attempt):
|
|
attempt = self._require_attempt(attempt)
|
|
snapshot = attempt.snapshot
|
|
runtime_id = snapshot.runtime.container_id
|
|
self._require_stopped(
|
|
self._inspect(snapshot.edge.container_id), snapshot.edge,
|
|
EDGE_SERVICE, runtime_id,
|
|
)
|
|
self._require_stopped(
|
|
self._inspect(runtime_id), snapshot.runtime,
|
|
RUNTIME_SERVICE, runtime_id,
|
|
)
|
|
if (
|
|
self._image_id(RUNTIME_IMAGE) != snapshot.runtime_image_id
|
|
or self._image_id(EDGE_IMAGE) != snapshot.edge_image_id
|
|
):
|
|
raise HostLifecycleError('identity')
|
|
self._compose('rm', '--force', EDGE_SERVICE, timeout=REMOVE_TIMEOUT)
|
|
self._compose('rm', '--force', RUNTIME_SERVICE, timeout=REMOVE_TIMEOUT)
|
|
attempt.phase = 'forward-removed'
|
|
if self._image_id(RUNTIME_IMAGE) != snapshot.runtime_image_id:
|
|
raise HostLifecycleError('identity')
|
|
self._compose(
|
|
'up', '--detach', '--no-deps', '--no-build', '--pull', 'never',
|
|
'--force-recreate', RUNTIME_SERVICE, timeout=RECREATE_TIMEOUT,
|
|
)
|
|
new_runtime_id = self._resolve(RUNTIME_SERVICE)
|
|
attempt.forward_runtime_id = new_runtime_id
|
|
if new_runtime_id == runtime_id:
|
|
raise HostLifecycleError('identity')
|
|
new_runtime = self._inspect(new_runtime_id)
|
|
self._require_running(
|
|
new_runtime, RUNTIME_SERVICE, snapshot.runtime_image_id,
|
|
new_runtime_id, fresh=True, config_hash=snapshot.runtime.config_hash,
|
|
)
|
|
self._wait_runtime(
|
|
new_runtime_id, snapshot.runtime_image_id,
|
|
snapshot.runtime.config_hash,
|
|
)
|
|
if self._image_id(EDGE_IMAGE) != snapshot.edge_image_id:
|
|
raise HostLifecycleError('identity')
|
|
self._compose(
|
|
'up', '--detach', '--no-deps', '--no-build', '--pull', 'never',
|
|
'--force-recreate', EDGE_SERVICE, timeout=RECREATE_TIMEOUT,
|
|
)
|
|
new_edge_id = self._resolve(EDGE_SERVICE)
|
|
attempt.forward_edge_id = new_edge_id
|
|
if new_edge_id == snapshot.edge.container_id:
|
|
raise HostLifecycleError('identity')
|
|
new_edge = self._inspect(new_edge_id)
|
|
self._require_metadata(
|
|
new_edge, EDGE_SERVICE, snapshot.edge_image_id, new_runtime_id,
|
|
)
|
|
attempt.forward_edge_config_hash = new_edge.config_hash
|
|
self._wait_edge(
|
|
new_edge_id, snapshot.edge_image_id, new_runtime_id,
|
|
attempt.forward_edge_config_hash,
|
|
)
|
|
attempt.phase = 'forward-healthy'
|
|
|
|
@staticmethod
|
|
def _attempt_edge_config_hash(attempt, edge_id):
|
|
if edge_id == attempt.snapshot.edge.container_id:
|
|
return attempt.snapshot.edge.config_hash
|
|
if (
|
|
edge_id == attempt.forward_edge_id
|
|
and _HEX_ID.fullmatch(attempt.forward_edge_config_hash or '')
|
|
):
|
|
return attempt.forward_edge_config_hash
|
|
raise HostLifecycleError('rollback')
|
|
|
|
def _require_quiescent(self, current, service, image_id, runtime_id, config_hash):
|
|
self._require_metadata(
|
|
current, service, image_id, runtime_id, config_hash=config_hash,
|
|
)
|
|
if (
|
|
current.status != 'exited' or current.running or current.paused
|
|
or current.restarting or current.dead or current.pid != 0
|
|
):
|
|
raise HostLifecycleError('rollback')
|
|
|
|
def _quiesce_service(
|
|
self, service, current_id, image_id, runtime_id, config_hash, timeout, grace,
|
|
):
|
|
if current_id is None:
|
|
return
|
|
current = self._inspect(current_id)
|
|
self._require_metadata(
|
|
current, service, image_id, runtime_id, config_hash=config_hash,
|
|
)
|
|
if current.running:
|
|
try:
|
|
self._compose(
|
|
'stop', '--timeout', str(grace), service, timeout=timeout,
|
|
)
|
|
except HostLifecycleError:
|
|
pass
|
|
if self._resolve_optional(service) != current_id:
|
|
raise HostLifecycleError('rollback')
|
|
current = self._inspect(current_id)
|
|
self._require_quiescent(
|
|
current, service, image_id, runtime_id, config_hash,
|
|
)
|
|
|
|
def quiesce_for_rollback(self, attempt, operation_id):
|
|
attempt = self._require_attempt(attempt)
|
|
snapshot = attempt.snapshot
|
|
runtime_id = self._resolve_optional(RUNTIME_SERVICE)
|
|
edge_id = self._resolve_optional(EDGE_SERVICE)
|
|
if edge_id is not None and runtime_id is None:
|
|
raise HostLifecycleError('rollback')
|
|
edge_config_hash = (
|
|
self._attempt_edge_config_hash(attempt, edge_id)
|
|
if edge_id is not None else None
|
|
)
|
|
self._quiesce_service(
|
|
EDGE_SERVICE, edge_id, snapshot.edge_image_id, runtime_id,
|
|
edge_config_hash, EDGE_STOP_TIMEOUT, 30,
|
|
)
|
|
self._quiesce_service(
|
|
RUNTIME_SERVICE, runtime_id, snapshot.runtime_image_id, runtime_id,
|
|
snapshot.runtime.config_hash, RUNTIME_STOP_TIMEOUT, 600,
|
|
)
|
|
if (
|
|
self._image_id(RUNTIME_IMAGE) != snapshot.runtime_image_id
|
|
or self._image_id(EDGE_IMAGE) != snapshot.edge_image_id
|
|
):
|
|
raise HostLifecycleError('rollback')
|
|
attempt.phase = 'rollback-quiescent'
|
|
return _new_stopped_runtime_proof(operation_id, purpose='rollback')
|
|
|
|
def recreate_restored_and_verify(self, attempt):
|
|
attempt = self._require_attempt(attempt)
|
|
if attempt.phase != 'rollback-quiescent':
|
|
raise HostLifecycleError('state')
|
|
snapshot = attempt.snapshot
|
|
edge_id = self._resolve_optional(EDGE_SERVICE)
|
|
runtime_id = self._resolve_optional(RUNTIME_SERVICE)
|
|
if edge_id is not None:
|
|
self._require_quiescent(
|
|
self._inspect(edge_id), EDGE_SERVICE, snapshot.edge_image_id,
|
|
runtime_id, self._attempt_edge_config_hash(attempt, edge_id),
|
|
)
|
|
self._compose('rm', '--force', EDGE_SERVICE, timeout=REMOVE_TIMEOUT)
|
|
if runtime_id is not None:
|
|
self._require_quiescent(
|
|
self._inspect(runtime_id), RUNTIME_SERVICE,
|
|
snapshot.runtime_image_id, runtime_id,
|
|
snapshot.runtime.config_hash,
|
|
)
|
|
self._compose('rm', '--force', RUNTIME_SERVICE, timeout=REMOVE_TIMEOUT)
|
|
if self._image_id(RUNTIME_IMAGE) != snapshot.runtime_image_id:
|
|
raise HostLifecycleError('rollback')
|
|
self._compose(
|
|
'up', '--detach', '--no-deps', '--no-build', '--pull', 'never',
|
|
'--force-recreate', RUNTIME_SERVICE, timeout=RECREATE_TIMEOUT,
|
|
)
|
|
restored_runtime_id = self._resolve(RUNTIME_SERVICE)
|
|
if restored_runtime_id in {
|
|
snapshot.runtime.container_id, attempt.forward_runtime_id,
|
|
}:
|
|
raise HostLifecycleError('rollback')
|
|
restored_runtime = self._inspect(restored_runtime_id)
|
|
self._require_running(
|
|
restored_runtime, RUNTIME_SERVICE, snapshot.runtime_image_id,
|
|
restored_runtime_id, fresh=True,
|
|
config_hash=snapshot.runtime.config_hash,
|
|
)
|
|
self._wait_runtime(
|
|
restored_runtime_id, snapshot.runtime_image_id,
|
|
snapshot.runtime.config_hash,
|
|
)
|
|
if self._image_id(EDGE_IMAGE) != snapshot.edge_image_id:
|
|
raise HostLifecycleError('rollback')
|
|
self._compose(
|
|
'up', '--detach', '--no-deps', '--no-build', '--pull', 'never',
|
|
'--force-recreate', EDGE_SERVICE, timeout=RECREATE_TIMEOUT,
|
|
)
|
|
restored_edge_id = self._resolve(EDGE_SERVICE)
|
|
if restored_edge_id in {snapshot.edge.container_id, attempt.forward_edge_id}:
|
|
raise HostLifecycleError('rollback')
|
|
restored_edge = self._inspect(restored_edge_id)
|
|
self._require_metadata(
|
|
restored_edge, EDGE_SERVICE, snapshot.edge_image_id,
|
|
restored_runtime_id,
|
|
)
|
|
self._wait_edge(
|
|
restored_edge_id, snapshot.edge_image_id, restored_runtime_id,
|
|
restored_edge.config_hash,
|
|
)
|
|
attempt.phase = 'rollback-healthy'
|
|
|
|
def contain_for_failed_hold(self, attempt, operation_id):
|
|
try:
|
|
self.quiesce_for_rollback(attempt, operation_id)
|
|
attempt.phase = 'failed-hold-contained'
|
|
return True
|
|
except Exception:
|
|
attempt.phase = 'failed-hold-uncontained'
|
|
return False
|
|
|
|
|
|
def execute_fixed_forward(session, lifecycle=None):
|
|
if getattr(session, '_entered', False) is not True:
|
|
raise HostLifecycleError('state')
|
|
lifecycle = lifecycle or FixedDeploymentLifecycle()
|
|
session.backup()
|
|
snapshot = lifecycle.preflight()
|
|
attempt = lifecycle.begin_attempt(snapshot)
|
|
session.revalidate_for_stop()
|
|
lifecycle.stop_cleanly(attempt)
|
|
proof = lifecycle.authorize_replacement(
|
|
attempt, session.request.operation_id,
|
|
)
|
|
resulting_identity = session.replace(proof)
|
|
lifecycle.recreate_and_verify(attempt)
|
|
return resulting_identity
|
|
|
|
|
|
def _forward_failure(request, error):
|
|
category = getattr(error, 'category', '')
|
|
if category == 'health':
|
|
return 'health_check_failed', 'health_check_failed'
|
|
if request.action.value == 'restart':
|
|
return 'restart_failed', 'restart_failed'
|
|
return 'apply_failed', 'apply_failed'
|
|
|
|
|
|
def _preflight_failure(error):
|
|
category = getattr(error, 'category', '')
|
|
if category == 'validation':
|
|
return 'validation_failed', 'validation_failed'
|
|
if category in ('identity', 'partial'):
|
|
return 'operation_conflict', 'bounded_result'
|
|
return 'apply_failed', 'apply_failed'
|
|
|
|
|
|
def _terminal_result_for_phase(session, phase):
|
|
name = phase['phase']
|
|
if name == 'succeeded':
|
|
identity = {
|
|
'active_config_sha256': (
|
|
session.request.candidate_config_sha256
|
|
or session.request.active_config_sha256
|
|
),
|
|
'active_secrets_sha256': (
|
|
session.request.candidate_secrets_sha256
|
|
or session.request.active_secrets_sha256
|
|
),
|
|
}
|
|
return name, None, None, identity
|
|
if name in {'failed', 'rolled_back'}:
|
|
category = phase.get('forward_category') or 'apply_failed'
|
|
detail = phase.get('safe_detail') or category
|
|
return name, category, detail, session.original_identity()
|
|
if name == 'failed_hold':
|
|
return name, 'rollback_failed', 'rollback_failed', None
|
|
raise HostLifecycleError('state')
|
|
|
|
|
|
def execute_fixed_operation(session, lifecycle=None, state=None):
|
|
"""Run one forward attempt and at most one fixed rollback attempt."""
|
|
if getattr(session, '_entered', False) is not True:
|
|
raise HostLifecycleError('state')
|
|
lifecycle = lifecycle or FixedDeploymentLifecycle()
|
|
state = state or HostOperationState(session.request)
|
|
try:
|
|
terminal = state.terminal_result()
|
|
if terminal is not None:
|
|
return terminal
|
|
phase = state.initialize(session.publication_state)
|
|
except HostStateError:
|
|
raise HostLifecycleError('state') from None
|
|
if getattr(session, '_failed_hold_replay', False):
|
|
if phase['phase'] == 'forward_started':
|
|
try:
|
|
phase = state.advance(
|
|
'forward_started', 'rollback_started',
|
|
phase['publication_state'],
|
|
forward_category=phase.get('forward_category'),
|
|
safe_detail=phase.get('safe_detail'),
|
|
)
|
|
except HostStateError:
|
|
raise HostLifecycleError('state') from None
|
|
if phase['phase'] == 'rollback_started':
|
|
try:
|
|
phase = state.advance(
|
|
'rollback_started', 'failed_hold',
|
|
phase['publication_state'],
|
|
forward_category=phase.get('forward_category'),
|
|
safe_detail='rollback_failed',
|
|
containment_confirmed=False,
|
|
)
|
|
except HostStateError:
|
|
raise HostLifecycleError('state') from None
|
|
elif phase['phase'] != 'failed_hold':
|
|
raise HostLifecycleError('state')
|
|
if phase['phase'] in {'succeeded', 'failed', 'rolled_back', 'failed_hold'}:
|
|
result, category, detail, identity = _terminal_result_for_phase(
|
|
session, phase,
|
|
)
|
|
try:
|
|
return state.publish_result(
|
|
result, safe_category=category, safe_detail=detail,
|
|
resulting_identity=identity,
|
|
)
|
|
except HostStateError:
|
|
raise HostLifecycleError('state') from None
|
|
|
|
current_phase = phase['phase']
|
|
attempt = None
|
|
cancellation = None
|
|
rollback_required = current_phase in {'forward_started', 'rollback_started'}
|
|
rollback_required = rollback_required or session.publication_state != 'original'
|
|
forward_category = phase.get('forward_category')
|
|
if rollback_required and forward_category is None:
|
|
forward_category = (
|
|
'restart_failed'
|
|
if session.request.action.value == 'restart' else 'apply_failed'
|
|
)
|
|
|
|
if not rollback_required:
|
|
try:
|
|
session.backup()
|
|
snapshot = lifecycle.preflight()
|
|
attempt = lifecycle.begin_attempt(snapshot)
|
|
session.revalidate_for_stop()
|
|
state.advance(
|
|
'prepared', 'forward_started', session.publication_state,
|
|
)
|
|
current_phase = 'forward_started'
|
|
lifecycle.stop_cleanly(attempt)
|
|
proof = lifecycle.authorize_replacement(
|
|
attempt, session.request.operation_id,
|
|
)
|
|
resulting_identity = session.replace(proof)
|
|
lifecycle.recreate_and_verify(attempt)
|
|
state.advance(
|
|
'forward_started', 'succeeded', session.publication_state,
|
|
)
|
|
current_phase = 'succeeded'
|
|
result = state.publish_result(
|
|
'succeeded', safe_category=None, safe_detail=None,
|
|
resulting_identity=resulting_identity,
|
|
)
|
|
return result
|
|
except BaseException as error:
|
|
is_cancellation = not isinstance(error, Exception)
|
|
if current_phase == 'succeeded':
|
|
if is_cancellation:
|
|
raise
|
|
raise HostLifecycleError('state') from None
|
|
if (
|
|
isinstance(error, HostStateError)
|
|
and error.category == 'uncertain'
|
|
):
|
|
if error.cancellation is not None:
|
|
raise error.cancellation
|
|
raise HostLifecycleError('state') from None
|
|
if attempt is None or not attempt.mutation_started:
|
|
if is_cancellation:
|
|
raise
|
|
category, detail = _preflight_failure(error)
|
|
try:
|
|
state.advance(
|
|
'prepared', 'failed', session.publication_state,
|
|
forward_category=category, safe_detail=detail,
|
|
)
|
|
current_phase = 'failed'
|
|
result = state.publish_result(
|
|
'failed', safe_category=category, safe_detail=detail,
|
|
resulting_identity=session.original_identity(),
|
|
)
|
|
return result
|
|
except HostStateError:
|
|
raise HostLifecycleError('state') from None
|
|
rollback_required = True
|
|
if is_cancellation:
|
|
cancellation = error
|
|
forward_category, _ = _forward_failure(session.request, error)
|
|
|
|
if attempt is None:
|
|
try:
|
|
session.backup()
|
|
snapshot = lifecycle.preflight()
|
|
attempt = lifecycle.begin_attempt(snapshot)
|
|
except BaseException as error:
|
|
if not isinstance(error, Exception) and not rollback_required:
|
|
raise
|
|
category, detail = _preflight_failure(error)
|
|
try:
|
|
if current_phase != 'rollback_started':
|
|
state.advance(
|
|
current_phase, 'rollback_started',
|
|
session.publication_state,
|
|
forward_category=forward_category or category,
|
|
safe_detail=detail,
|
|
)
|
|
current_phase = 'rollback_started'
|
|
state.publish_failed_hold(
|
|
forward_category=forward_category or category,
|
|
publication_state=session.publication_state,
|
|
containment_confirmed=False,
|
|
)
|
|
state.advance(
|
|
'rollback_started', 'failed_hold',
|
|
session.publication_state,
|
|
forward_category=forward_category or category,
|
|
safe_detail='rollback_failed',
|
|
containment_confirmed=False,
|
|
)
|
|
current_phase = 'failed_hold'
|
|
result = state.publish_result(
|
|
'failed_hold', safe_category='rollback_failed',
|
|
safe_detail='rollback_failed', resulting_identity=None,
|
|
)
|
|
if not isinstance(error, Exception):
|
|
raise error
|
|
return result
|
|
except HostStateError:
|
|
if not isinstance(error, Exception):
|
|
raise error
|
|
raise HostLifecycleError('state') from None
|
|
|
|
rollback_terminal_transition = False
|
|
try:
|
|
if current_phase != 'rollback_started':
|
|
state.advance(
|
|
current_phase, 'rollback_started', session.publication_state,
|
|
forward_category=forward_category, safe_detail=forward_category,
|
|
)
|
|
current_phase = 'rollback_started'
|
|
proof = lifecycle.quiesce_for_rollback(
|
|
attempt, session.request.operation_id,
|
|
)
|
|
original_identity = session.restore_backups(proof)
|
|
lifecycle.recreate_restored_and_verify(attempt)
|
|
detail = forward_category
|
|
rollback_terminal_transition = True
|
|
state.advance(
|
|
'rollback_started', 'rolled_back', session.publication_state,
|
|
forward_category=forward_category, safe_detail=detail,
|
|
)
|
|
current_phase = 'rolled_back'
|
|
result = state.publish_result(
|
|
'rolled_back', safe_category=forward_category, safe_detail=detail,
|
|
resulting_identity=original_identity,
|
|
)
|
|
if cancellation is not None:
|
|
raise cancellation
|
|
return result
|
|
except BaseException as error:
|
|
if current_phase == 'rolled_back':
|
|
if cancellation is not None:
|
|
raise cancellation
|
|
if not isinstance(error, Exception):
|
|
raise
|
|
raise HostLifecycleError('state') from None
|
|
if (
|
|
isinstance(error, HostStateError)
|
|
and error.category == 'uncertain'
|
|
and rollback_terminal_transition
|
|
):
|
|
if error.cancellation is not None:
|
|
raise error.cancellation
|
|
raise HostLifecycleError('state') from None
|
|
if (
|
|
isinstance(error, HostStateError)
|
|
and error.category == 'uncertain'
|
|
and error.cancellation is not None
|
|
):
|
|
cancellation = error.cancellation
|
|
if not isinstance(error, Exception):
|
|
cancellation = error
|
|
try:
|
|
state.publish_failed_hold(
|
|
forward_category=forward_category,
|
|
publication_state=session.publication_state,
|
|
containment_confirmed=False,
|
|
)
|
|
except HostStateError:
|
|
if cancellation is not None:
|
|
raise cancellation
|
|
raise HostLifecycleError('state') from None
|
|
contained = lifecycle.contain_for_failed_hold(
|
|
attempt, session.request.operation_id,
|
|
)
|
|
try:
|
|
state.advance(
|
|
'rollback_started', 'failed_hold', session.publication_state,
|
|
forward_category=forward_category, safe_detail='rollback_failed',
|
|
containment_confirmed=contained,
|
|
)
|
|
current_phase = 'failed_hold'
|
|
result = state.publish_result(
|
|
'failed_hold', safe_category='rollback_failed',
|
|
safe_detail='rollback_failed', resulting_identity=None,
|
|
)
|
|
if cancellation is not None:
|
|
raise cancellation
|
|
return result
|
|
except HostStateError:
|
|
if cancellation is not None:
|
|
raise cancellation
|
|
raise HostLifecycleError('state') from None
|