Files
truf-server/app/host_agent_apply.py
2026-09-30 20:30:56 +03:00

1120 lines
39 KiB
Python

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