from dataclasses import dataclass, field import hashlib import hmac import os from pathlib import Path import stat from host_agent_protocol import ( HostAgentAction, HostAgentProtocolError, HostAgentRequest, decode_request_payload, encode_request_payload, ) from host_agent_state import HostStateError, failed_hold_operation from runtime_document import ( MAX_CONFIG_DOCUMENT_BYTES, MAX_SECRETS_DOCUMENT_BYTES, RuntimeDocumentError, preview_runtime_documents, ) from runtime_security import ( PrivateFileLock, durable_replace, fsync_directory, reject_reparse_components, ) HOST_ROOT_UID = 0 HOST_ROOT_GID = 0 HOST_RUNTIME_UID = 10001 HOST_RUNTIME_GID = 10001 HOST_ACTIVE_DIRECTORY = Path('/etc/truf/runtime') HOST_ACTIVE_DIRECTORY_MODE = 0o755 HOST_ACTIVE_CONFIG_PATH = HOST_ACTIVE_DIRECTORY / 'config.yaml' HOST_ACTIVE_SECRETS_PATH = HOST_ACTIVE_DIRECTORY / 'secrets.yaml' HOST_CANDIDATE_DIRECTORY = Path('/var/lib/truf/runtime-document-candidates') HOST_CANDIDATE_DIRECTORY_MODE = 0o700 HOST_CONFIG_CANDIDATE_PATH = HOST_CANDIDATE_DIRECTORY / 'config.yaml' HOST_SECRETS_CANDIDATE_PATH = HOST_CANDIDATE_DIRECTORY / 'secrets.yaml' HOST_APPLY_DIRECTORY = Path('/run/truf-host-agent') HOST_APPLY_LOCK_PATH = HOST_APPLY_DIRECTORY / 'apply.lock' HOST_BACKUP_ROOT = Path('/var/lib/truf/host-agent/backups') HOST_BACKUP_DIRECTORY_MODE = 0o700 HOST_TEMPLATE_PATH = Path(__file__).with_name('config.linux.yaml') class HostApplyError(RuntimeError): def __init__(self, category): self.category = category super().__init__('host runtime apply failed') _STOPPED_RUNTIME_AUTHORITY = object() @dataclass(frozen=True, slots=True) class _StoppedRuntimeProof: operation_id: str purpose: str nonce: object = field(repr=False) authority: object = field(repr=False) def _new_stopped_runtime_proof(operation_id, *, purpose='forward'): if purpose not in ('forward', 'rollback'): raise HostApplyError('state') return _StoppedRuntimeProof( str(operation_id), purpose, object(), _STOPPED_RUNTIME_AUTHORITY, ) @dataclass(frozen=True, slots=True) class _FileSnapshot: payload: bytes = field(repr=False) sha256: str byte_count: int identity: tuple def _expected_identity(request): return { 'active_config_sha256': request.active_config_sha256, 'active_secrets_sha256': request.active_secrets_sha256, 'candidate_config_sha256': request.candidate_config_sha256, 'candidate_secrets_sha256': request.candidate_secrets_sha256, } def _require_directory(path, *, uid, gid, mode): try: reject_reparse_components(path) details = os.stat(path, follow_symlinks=False) if not stat.S_ISDIR(details.st_mode): raise OSError('not a directory') if os.name != 'nt' and ( details.st_uid != uid or details.st_gid != gid or stat.S_IMODE(details.st_mode) != mode ): raise OSError('directory metadata') return details except HostApplyError: raise except Exception: raise HostApplyError('filesystem') from None def _file_identity(details): return ( details.st_dev, details.st_ino, details.st_size, getattr(details, 'st_mtime_ns', None), None if os.name == 'nt' else getattr(details, 'st_ctime_ns', None), ) def _require_file_metadata(details, *, uid, gid, mode=None, immutable=False): if not stat.S_ISREG(details.st_mode) or details.st_nlink != 1: raise OSError('file type') if os.name == 'nt': return if details.st_uid != uid or details.st_gid != gid: raise OSError('file owner') permissions = stat.S_IMODE(details.st_mode) if mode is not None and permissions != mode: raise OSError('file mode') if immutable and permissions & 0o022: raise OSError('mutable trusted file') def _snapshot_file( path, maximum, *, uid, gid, mode=None, immutable=False, expected_sha256=None, ): descriptor = None payload = None try: path = reject_reparse_components(path) flags = os.O_RDONLY if hasattr(os, 'O_BINARY'): flags |= os.O_BINARY if hasattr(os, 'O_CLOEXEC'): flags |= os.O_CLOEXEC if hasattr(os, 'O_NOFOLLOW'): flags |= os.O_NOFOLLOW descriptor = os.open(path, flags) before = os.fstat(descriptor) _require_file_metadata( before, uid=uid, gid=gid, mode=mode, immutable=immutable, ) with os.fdopen(descriptor, 'rb') as handle: descriptor = None payload = handle.read(maximum + 1) after = os.fstat(handle.fileno()) current = os.stat(path, follow_symlinks=False) _require_file_metadata( after, uid=uid, gid=gid, mode=mode, immutable=immutable, ) _require_file_metadata( current, uid=uid, gid=gid, mode=mode, immutable=immutable, ) if _file_identity(before) != _file_identity(after): raise OSError('file changed during read') if _file_identity(after) != _file_identity(current): raise OSError('file path changed during read') if len(payload) > maximum: raise HostApplyError('size') digest = hashlib.sha256(payload).hexdigest() if expected_sha256 is not None and not hmac.compare_digest( digest, expected_sha256, ): raise HostApplyError('identity') return _FileSnapshot( payload=payload, sha256=digest, byte_count=len(payload), identity=_file_identity(current), ) except HostApplyError: raise except Exception: payload = None raise HostApplyError('filesystem') from None finally: if descriptor is not None: os.close(descriptor) def _create_owned_file(path, payload, *, uid, gid, mode, expected_sha256): descriptor = None created = False try: reject_reparse_components(Path(path).parent) flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL if hasattr(os, 'O_BINARY'): flags |= os.O_BINARY if hasattr(os, 'O_CLOEXEC'): flags |= os.O_CLOEXEC if hasattr(os, 'O_NOFOLLOW'): flags |= os.O_NOFOLLOW descriptor = os.open(path, flags, mode) created = True if os.name != 'nt': os.fchmod(descriptor, mode) details = os.fstat(descriptor) if details.st_uid != uid or details.st_gid != gid: os.fchown(descriptor, uid, gid) view = memoryview(payload) written = 0 while written < len(view): count = os.write(descriptor, view[written:]) if count <= 0: raise OSError('short write') written += count os.fsync(descriptor) os.close(descriptor) descriptor = None return _snapshot_file( path, len(payload), uid=uid, gid=gid, mode=mode, expected_sha256=expected_sha256, ) except BaseException: if descriptor is not None: os.close(descriptor) if created: try: os.unlink(path) fsync_directory(Path(path).parent) except OSError: pass raise def _remove_stale_stage(path): try: reject_reparse_components(Path(path).parent) details = os.stat(path, follow_symlinks=False) if stat.S_ISDIR(details.st_mode): raise OSError('stage is a directory') os.unlink(path) fsync_directory(Path(path).parent) except FileNotFoundError: return except Exception: raise HostApplyError('filesystem') from None def _read_descriptor(descriptor, maximum): os.lseek(descriptor, 0, os.SEEK_SET) chunks = [] remaining = maximum + 1 while remaining: chunk = os.read(descriptor, min(remaining, 64 * 1024)) if not chunk: break chunks.append(chunk) remaining -= len(chunk) return b''.join(chunks) def _open_displaced_guard(path, original, maximum, *, allow_root=False): if os.name == 'nt': return None descriptor = None try: flags = os.O_RDONLY | getattr(os, 'O_CLOEXEC', 0) | getattr(os, 'O_NOFOLLOW', 0) descriptor = os.open(path, flags) details = os.fstat(descriptor) try: _require_file_metadata( details, uid=HOST_RUNTIME_UID, gid=HOST_RUNTIME_GID, mode=0o600, ) except OSError: if not allow_root: raise _require_file_metadata( details, uid=HOST_ROOT_UID, gid=HOST_ROOT_GID, mode=0o600, ) payload = _read_descriptor(descriptor, maximum) if ( _file_identity(details) != original.identity or len(payload) > maximum or not hmac.compare_digest(payload, original.payload) ): raise HostApplyError('identity') return descriptor except HostApplyError: if descriptor is not None: os.close(descriptor) raise except Exception: if descriptor is not None: os.close(descriptor) raise HostApplyError('filesystem') from None def _verify_displaced_guard(descriptor, original, maximum): if descriptor is None: return try: payload = _read_descriptor(descriptor, maximum) details = os.fstat(descriptor) if ( not stat.S_ISREG(details.st_mode) or details.st_dev != original.identity[0] or details.st_ino != original.identity[1] or len(payload) > maximum or not hmac.compare_digest(payload, original.payload) ): raise HostApplyError('identity') except HostApplyError: raise except Exception: raise HostApplyError('filesystem') from None def _adopt_published_file(path, source): descriptor = None try: staged = _snapshot_file( path, source.byte_count, uid=HOST_ROOT_UID, gid=HOST_ROOT_GID, mode=0o600, expected_sha256=source.sha256, ) if not hmac.compare_digest(staged.payload, source.payload): raise HostApplyError('identity') flags = os.O_RDWR | getattr(os, 'O_CLOEXEC', 0) | getattr(os, 'O_NOFOLLOW', 0) descriptor = os.open(path, flags) details = os.fstat(descriptor) _require_file_metadata( details, uid=HOST_ROOT_UID, gid=HOST_ROOT_GID, mode=0o600, ) if _file_identity(details) != staged.identity: raise HostApplyError('identity') if os.name != 'nt': os.fchown(descriptor, HOST_RUNTIME_UID, HOST_RUNTIME_GID) os.fchmod(descriptor, 0o600) os.fsync(descriptor) os.close(descriptor) descriptor = None fsync_directory(Path(path).parent) final = _snapshot_file( path, source.byte_count, uid=HOST_RUNTIME_UID, gid=HOST_RUNTIME_GID, mode=0o600, expected_sha256=source.sha256, ) if not hmac.compare_digest(final.payload, source.payload): raise HostApplyError('identity') except HostApplyError: raise except Exception: raise HostApplyError('filesystem') from None finally: if descriptor is not None: os.close(descriptor) def _snapshot_active(path, maximum, *, allow_root=False, expected_sha256=None): if os.name == 'nt': return _snapshot_file( path, maximum, uid=HOST_RUNTIME_UID, gid=HOST_RUNTIME_GID, mode=0o600, expected_sha256=expected_sha256, ), False try: reject_reparse_components(path) details = os.stat(path, follow_symlinks=False) runtime_owned = ( details.st_uid == HOST_RUNTIME_UID and details.st_gid == HOST_RUNTIME_GID ) root_owned = ( details.st_uid == HOST_ROOT_UID and details.st_gid == HOST_ROOT_GID and not runtime_owned ) if not runtime_owned and not (allow_root and root_owned): raise OSError('active file owner') uid = HOST_ROOT_UID if root_owned else HOST_RUNTIME_UID gid = HOST_ROOT_GID if root_owned else HOST_RUNTIME_GID return _snapshot_file( path, maximum, uid=uid, gid=gid, mode=0o600, expected_sha256=expected_sha256, ), root_owned except HostApplyError: raise except Exception: raise HostApplyError('filesystem') from None def _same_snapshot(left, right): return ( left.sha256 == right.sha256 and left.byte_count == right.byte_count and left.identity == right.identity ) class HostApplySession: """One fixed-path host apply claim held under the global apply lock.""" def __init__( self, request, database, *, package_capabilities=None, package_capability_provider=None, ): try: request = decode_request_payload(encode_request_payload(request)) except HostAgentProtocolError: raise HostApplyError('request') self.request = request self.database = database self.package_capabilities = package_capabilities self.package_capability_provider = package_capability_provider self.claim = None self._lock = None self._active = None self._candidates = None self._selected = None self._backup_complete = False self._publication_complete = False self._publication_state = 'original' self._pending_adoption = set() self._used_proof_nonces = set() self._failed_hold_replay = False self._entered = False def __enter__(self): if self._entered: raise HostApplyError('state') try: self._lock = PrivateFileLock(HOST_APPLY_LOCK_PATH).acquire() except BlockingIOError: raise HostApplyError('busy') from None except Exception: raise HostApplyError('filesystem') from None self._entered = True try: try: held_operation = failed_hold_operation() if ( held_operation is not None and held_operation != self.request.operation_id ): raise HostApplyError('failed_hold') self._failed_hold_replay = ( held_operation == self.request.operation_id ) except HostStateError: raise HostApplyError('failed_hold') from None connection = getattr(self.database, 'conn', None) if connection is None or not connection.is_postgres: raise HostApplyError('authority') try: self.claim = self.database.claim_runtime_operation_execution( operation_id=self.request.operation_id, action=self.request.action.value, expected_identity=_expected_identity(self.request), ) except Exception: raise HostApplyError('authority') from None if self._failed_hold_replay: return self self._prepare() return self except BaseException: self.close() raise def _prepare(self): _require_directory( HOST_ACTIVE_DIRECTORY, uid=HOST_ROOT_UID, gid=HOST_ROOT_GID, mode=HOST_ACTIVE_DIRECTORY_MODE, ) candidate_names = { HostAgentAction.APPLY_CONFIG: ('config',), HostAgentAction.APPLY_SECRETS: ('secrets',), HostAgentAction.APPLY_BOTH: ('config', 'secrets'), HostAgentAction.RESTART: (), }[self.request.action] active = {} root_owned = set() root_original = set() active_paths = { 'config': (HOST_ACTIVE_CONFIG_PATH, MAX_CONFIG_DOCUMENT_BYTES), 'secrets': (HOST_ACTIVE_SECRETS_PATH, MAX_SECRETS_DOCUMENT_BYTES), } for name, (path, maximum) in active_paths.items(): active[name], is_root_owned = _snapshot_active( path, maximum, allow_root=( bool(self.claim.get('replayed')) and name in candidate_names ), ) if is_root_owned: root_owned.add(name) candidates = {} if candidate_names: _require_directory( HOST_CANDIDATE_DIRECTORY, uid=HOST_RUNTIME_UID, gid=HOST_RUNTIME_GID, mode=HOST_CANDIDATE_DIRECTORY_MODE, ) if 'config' in candidate_names: candidates['config'] = _snapshot_file( HOST_CONFIG_CANDIDATE_PATH, MAX_CONFIG_DOCUMENT_BYTES, uid=HOST_RUNTIME_UID, gid=HOST_RUNTIME_GID, mode=0o600, expected_sha256=self.request.candidate_config_sha256, ) if 'secrets' in candidate_names: candidates['secrets'] = _snapshot_file( HOST_SECRETS_CANDIDATE_PATH, MAX_SECRETS_DOCUMENT_BYTES, uid=HOST_RUNTIME_UID, gid=HOST_RUNTIME_GID, mode=0o600, expected_sha256=self.request.candidate_secrets_sha256, ) original_hashes = { 'config': self.request.active_config_sha256, 'secrets': self.request.active_secrets_sha256, } published = [] for name, snapshot in active.items(): if hmac.compare_digest(snapshot.sha256, original_hashes[name]): if name in root_owned: if not self.claim.get('replayed') or name not in candidate_names: raise HostApplyError('identity') root_original.add(name) continue candidate = candidates.get(name) if ( self.claim.get('replayed') and candidate is not None and hmac.compare_digest(snapshot.sha256, candidate.sha256) and hmac.compare_digest(snapshot.payload, candidate.payload) ): published.append(name) continue raise HostApplyError('identity') if published or root_original: _require_directory( HOST_BACKUP_ROOT, uid=HOST_ROOT_UID, gid=HOST_ROOT_GID, mode=HOST_BACKUP_DIRECTORY_MODE, ) operation_directory = HOST_BACKUP_ROOT / self.request.operation_id _require_directory( operation_directory, uid=HOST_ROOT_UID, gid=HOST_ROOT_GID, mode=HOST_BACKUP_DIRECTORY_MODE, ) maximums = { 'config': MAX_CONFIG_DOCUMENT_BYTES, 'secrets': MAX_SECRETS_DOCUMENT_BYTES, } for name in candidate_names: active[name] = _snapshot_file( operation_directory / f'{name}.yaml', maximums[name], uid=HOST_ROOT_UID, gid=HOST_ROOT_GID, mode=0o600, expected_sha256=original_hashes[name], ) template = _snapshot_file( HOST_TEMPLATE_PATH, MAX_CONFIG_DOCUMENT_BYTES, uid=HOST_ROOT_UID, gid=HOST_ROOT_GID, immutable=True, ) try: capability_provider = self.package_capability_provider active_capabilities = ( capability_provider(active['config'].payload) if capability_provider is not None else self.package_capabilities ) effective_config = candidates.get('config', active['config']).payload candidate_capabilities = ( capability_provider(effective_config) if capability_provider is not None else self.package_capabilities ) preview_runtime_documents( active['config'].payload, active['secrets'].payload, config_template_payload=template.payload, package_capabilities=active_capabilities, ) preview_runtime_documents( effective_config, candidates.get('secrets', active['secrets']).payload, config_template_payload=template.payload, package_capabilities=candidate_capabilities, ) except (RuntimeDocumentError, OSError, ValueError): raise HostApplyError('validation') from None finally: active_capabilities = candidate_capabilities = effective_config = None self._active = active self._candidates = candidates self._selected = candidate_names if published or root_original: self._backup_complete = True self._publication_complete = len(published) == len(candidate_names) self._publication_state = ( 'candidate' if self._publication_complete else ( 'partial' if published else 'original' ) ) self._pending_adoption = root_owned.intersection(published) @property def publication_state(self): if not self._entered or ( self._active is None and not self._failed_hold_replay ): raise HostApplyError('state') return self._publication_state def original_identity(self): if not self._entered or self._active is None: raise HostApplyError('state') return { 'active_config_sha256': self._active['config'].sha256, 'active_secrets_sha256': self._active['secrets'].sha256, } def _consume_stopped_proof(self, stopped_proof, purpose): if ( not isinstance(stopped_proof, _StoppedRuntimeProof) or stopped_proof.authority is not _STOPPED_RUNTIME_AUTHORITY or stopped_proof.operation_id != self.request.operation_id or stopped_proof.purpose != purpose or stopped_proof.nonce in self._used_proof_nonces ): raise HostApplyError('state') self._used_proof_nonces.add(stopped_proof.nonce) def backup(self): if not self._entered or self._active is None: raise HostApplyError('state') if self._backup_complete: return if not self._selected: self._backup_complete = True return _require_directory( HOST_BACKUP_ROOT, uid=HOST_ROOT_UID, gid=HOST_ROOT_GID, mode=HOST_BACKUP_DIRECTORY_MODE, ) operation_directory = HOST_BACKUP_ROOT / self.request.operation_id try: os.mkdir(operation_directory, HOST_BACKUP_DIRECTORY_MODE) fsync_directory(HOST_BACKUP_ROOT) except FileExistsError: pass except Exception: raise HostApplyError('filesystem') from None _require_directory( operation_directory, uid=HOST_ROOT_UID, gid=HOST_ROOT_GID, mode=HOST_BACKUP_DIRECTORY_MODE, ) for name in self._selected: source = self._active[name] destination = operation_directory / f'{name}.yaml' try: stored = _create_owned_file( destination, source.payload, uid=HOST_ROOT_UID, gid=HOST_ROOT_GID, mode=0o600, expected_sha256=source.sha256, ) except FileExistsError: stored = _snapshot_file( destination, source.byte_count, uid=HOST_ROOT_UID, gid=HOST_ROOT_GID, mode=0o600, expected_sha256=source.sha256, ) except HostApplyError: raise except Exception: raise HostApplyError('filesystem') from None if not hmac.compare_digest(stored.payload, source.payload): raise HostApplyError('identity') fsync_directory(operation_directory) self._backup_complete = True def _revalidate(self): current_active = { 'config': _snapshot_file( HOST_ACTIVE_CONFIG_PATH, MAX_CONFIG_DOCUMENT_BYTES, uid=HOST_RUNTIME_UID, gid=HOST_RUNTIME_GID, mode=0o600, expected_sha256=self._active['config'].sha256, ), 'secrets': _snapshot_file( HOST_ACTIVE_SECRETS_PATH, MAX_SECRETS_DOCUMENT_BYTES, uid=HOST_RUNTIME_UID, gid=HOST_RUNTIME_GID, mode=0o600, expected_sha256=self._active['secrets'].sha256, ), } for name, original in self._active.items(): if not _same_snapshot(current_active[name], original): raise HostApplyError('identity') candidate_paths = { 'config': (HOST_CONFIG_CANDIDATE_PATH, MAX_CONFIG_DOCUMENT_BYTES), 'secrets': (HOST_SECRETS_CANDIDATE_PATH, MAX_SECRETS_DOCUMENT_BYTES), } for name, original in self._candidates.items(): path, maximum = candidate_paths[name] current = _snapshot_file( path, maximum, uid=HOST_RUNTIME_UID, gid=HOST_RUNTIME_GID, mode=0o600, expected_sha256=original.sha256, ) if not _same_snapshot(current, original): raise HostApplyError('identity') def revalidate_for_stop(self): if not self._entered or self._active is None or not self._backup_complete: raise HostApplyError('state') if self._publication_state == 'partial': raise HostApplyError('partial') if self._publication_complete: self._revalidate_published() else: self._revalidate() def _revalidate_published(self): active_paths = { 'config': (HOST_ACTIVE_CONFIG_PATH, MAX_CONFIG_DOCUMENT_BYTES), 'secrets': (HOST_ACTIVE_SECRETS_PATH, MAX_SECRETS_DOCUMENT_BYTES), } still_root_owned = set() for name, (path, maximum) in active_paths.items(): expected = self._candidates.get(name, self._active[name]) current, root_owned = _snapshot_active( path, maximum, allow_root=name in self._pending_adoption, expected_sha256=expected.sha256, ) if not hmac.compare_digest(current.payload, expected.payload): raise HostApplyError('identity') if root_owned: still_root_owned.add(name) candidate_paths = { 'config': (HOST_CONFIG_CANDIDATE_PATH, MAX_CONFIG_DOCUMENT_BYTES), 'secrets': (HOST_SECRETS_CANDIDATE_PATH, MAX_SECRETS_DOCUMENT_BYTES), } for name, original in self._candidates.items(): path, maximum = candidate_paths[name] current = _snapshot_file( path, maximum, uid=HOST_RUNTIME_UID, gid=HOST_RUNTIME_GID, mode=0o600, expected_sha256=original.sha256, ) if not _same_snapshot(current, original): raise HostApplyError('identity') operation_directory = HOST_BACKUP_ROOT / self.request.operation_id maximums = { 'config': MAX_CONFIG_DOCUMENT_BYTES, 'secrets': MAX_SECRETS_DOCUMENT_BYTES, } for name in self._selected: current = _snapshot_file( operation_directory / f'{name}.yaml', maximums[name], uid=HOST_ROOT_UID, gid=HOST_ROOT_GID, mode=0o600, expected_sha256=self._active[name].sha256, ) if not _same_snapshot(current, self._active[name]): raise HostApplyError('identity') self._pending_adoption = still_root_owned def replace(self, stopped_proof): if not self._entered or self._active is None or not self._backup_complete: raise HostApplyError('state') self._consume_stopped_proof(stopped_proof, 'forward') if self._publication_state == 'partial': raise HostApplyError('partial') if not self._selected: return { 'active_config_sha256': self._active['config'].sha256, 'active_secrets_sha256': self._active['secrets'].sha256, } if self._publication_complete: self._revalidate_published() for name in tuple(self._pending_adoption): path = ( HOST_ACTIVE_CONFIG_PATH if name == 'config' else HOST_ACTIVE_SECRETS_PATH ) _adopt_published_file(path, self._candidates[name]) self._pending_adoption.clear() self._revalidate_published() return { 'active_config_sha256': ( self._candidates.get('config', self._active['config']).sha256 ), 'active_secrets_sha256': ( self._candidates.get('secrets', self._active['secrets']).sha256 ), } active_paths = { 'config': HOST_ACTIVE_CONFIG_PATH, 'secrets': HOST_ACTIVE_SECRETS_PATH, } staged = {} guards = {} publication_started = False try: for name in self._selected: source = self._candidates[name] stage = HOST_ACTIVE_DIRECTORY / ( f'.{name}.yaml.{self.request.operation_id}.stage' ) _remove_stale_stage(stage) _create_owned_file( stage, source.payload, uid=HOST_ROOT_UID, gid=HOST_ROOT_GID, mode=0o600, expected_sha256=source.sha256, ) staged[name] = stage fsync_directory(HOST_ACTIVE_DIRECTORY) self._revalidate() maximums = { 'config': MAX_CONFIG_DOCUMENT_BYTES, 'secrets': MAX_SECRETS_DOCUMENT_BYTES, } for name in self._selected: guards[name] = _open_displaced_guard( active_paths[name], self._active[name], maximums[name], ) for name in self._selected: publication_started = True durable_replace(staged[name], active_paths[name]) staged.pop(name) source = self._candidates[name] _verify_displaced_guard( guards[name], self._active[name], maximums[name], ) descriptor = guards.pop(name) if descriptor is not None: os.close(descriptor) _adopt_published_file(active_paths[name], source) except BaseException as exc: for stage in staged.values(): try: os.unlink(stage) fsync_directory(HOST_ACTIVE_DIRECTORY) except OSError: pass if publication_started: self._publication_state = 'partial' raise HostApplyError('partial') from None if isinstance(exc, HostApplyError): raise raise HostApplyError('filesystem') from None finally: for descriptor in guards.values(): if descriptor is not None: os.close(descriptor) self._publication_complete = True self._publication_state = 'candidate' return { 'active_config_sha256': ( self._candidates.get('config', self._active['config']).sha256 ), 'active_secrets_sha256': ( self._candidates.get('secrets', self._active['secrets']).sha256 ), } def restore_backups(self, stopped_proof): if not self._entered or self._active is None or not self._backup_complete: raise HostApplyError('state') self._consume_stopped_proof(stopped_proof, 'rollback') if not self._selected: return self.original_identity() active_paths = { 'config': (HOST_ACTIVE_CONFIG_PATH, MAX_CONFIG_DOCUMENT_BYTES), 'secrets': (HOST_ACTIVE_SECRETS_PATH, MAX_SECRETS_DOCUMENT_BYTES), } candidate_paths = { 'config': (HOST_CONFIG_CANDIDATE_PATH, MAX_CONFIG_DOCUMENT_BYTES), 'secrets': (HOST_SECRETS_CANDIDATE_PATH, MAX_SECRETS_DOCUMENT_BYTES), } operation_directory = HOST_BACKUP_ROOT / self.request.operation_id _require_directory( operation_directory, uid=HOST_ROOT_UID, gid=HOST_ROOT_GID, mode=HOST_BACKUP_DIRECTORY_MODE, ) for name in self._selected: maximum = active_paths[name][1] backup = _snapshot_file( operation_directory / f'{name}.yaml', maximum, uid=HOST_ROOT_UID, gid=HOST_ROOT_GID, mode=0o600, expected_sha256=self._active[name].sha256, ) if not hmac.compare_digest(backup.payload, self._active[name].payload): raise HostApplyError('identity') for name, expected in self._candidates.items(): path, maximum = candidate_paths[name] current = _snapshot_file( path, maximum, uid=HOST_RUNTIME_UID, gid=HOST_RUNTIME_GID, mode=0o600, expected_sha256=expected.sha256, ) if not _same_snapshot(current, expected): raise HostApplyError('identity') observed = {} observed_root = set() states = {} for name, (path, maximum) in active_paths.items(): current, root_owned = _snapshot_active( path, maximum, allow_root=name in self._selected, ) observed[name] = current if root_owned: observed_root.add(name) if hmac.compare_digest(current.payload, self._active[name].payload): states[name] = 'original' elif ( name in self._selected and hmac.compare_digest( current.payload, self._candidates[name].payload, ) ): states[name] = 'candidate' else: raise HostApplyError('identity') staged = {} guards = {} restoration_started = False try: for name in self._selected: if states[name] == 'original': continue source = self._active[name] stage = HOST_ACTIVE_DIRECTORY / ( f'.{name}.yaml.{self.request.operation_id}.rollback' ) _remove_stale_stage(stage) _create_owned_file( stage, source.payload, uid=HOST_ROOT_UID, gid=HOST_ROOT_GID, mode=0o600, expected_sha256=source.sha256, ) staged[name] = stage fsync_directory(HOST_ACTIVE_DIRECTORY) for name, (path, maximum) in active_paths.items(): current, _ = _snapshot_active( path, maximum, allow_root=name in self._selected, ) if not _same_snapshot(current, observed[name]): raise HostApplyError('identity') for name in staged: path, maximum = active_paths[name] guards[name] = _open_displaced_guard( path, observed[name], maximum, allow_root=True, ) for name in self._selected: if name not in staged and name not in observed_root: continue path, maximum = active_paths[name] restoration_started = True self._publication_state = 'partial' if name in staged: durable_replace(staged[name], path) staged.pop(name) _verify_displaced_guard(guards[name], observed[name], maximum) descriptor = guards.pop(name) if descriptor is not None: os.close(descriptor) _adopt_published_file(path, self._active[name]) except BaseException as exc: for stage in staged.values(): try: os.unlink(stage) fsync_directory(HOST_ACTIVE_DIRECTORY) except OSError: pass if restoration_started: raise HostApplyError('rollback') from None if isinstance(exc, HostApplyError): raise raise HostApplyError('filesystem') from None finally: for descriptor in guards.values(): if descriptor is not None: os.close(descriptor) for name, (path, maximum) in active_paths.items(): current = _snapshot_file( path, maximum, uid=HOST_RUNTIME_UID, gid=HOST_RUNTIME_GID, mode=0o600, expected_sha256=self._active[name].sha256, ) if not hmac.compare_digest(current.payload, self._active[name].payload): raise HostApplyError('rollback') self._publication_complete = False self._publication_state = 'original' self._pending_adoption.clear() return self.original_identity() def close(self): lock = self._lock self._lock = None self._entered = False self.package_capabilities = None self.package_capability_provider = None self._active = None self._candidates = None self._selected = None self._publication_complete = False self._publication_state = 'original' self._pending_adoption.clear() self._used_proof_nonces.clear() if lock is not None: lock.release() def __exit__(self, exc_type, value, traceback): self.close() return False