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

1443 lines
51 KiB
Python

from dataclasses import dataclass, field
import hashlib
import hmac
import json
import os
from pathlib import Path
import re
import stat
from runtime_document import (
MAX_CONFIG_DOCUMENT_BYTES,
MAX_SECRETS_DOCUMENT_BYTES,
RuntimeDocumentError,
_resolve_package_manifest_path,
load_yaml_document,
preview_runtime_documents,
)
from runtime_security import (
PrivateFileLock,
durable_replace,
fsync_directory,
harden_private_file,
read_stable_root_file,
require_private_directory,
require_private_file,
)
from worker_package import (
MAX_WORKER_PACKAGE_MANIFEST_BYTES,
load_worker_package_manifest_bytes,
)
MANAGED_TEMPLATE_PATH = Path(__file__).with_name('config.linux.yaml')
MANAGED_WORKER_PACKAGE_DIRECTORY = Path('/data/worker-packages')
MANAGED_SECRETS_PATH = Path('/data/config/secrets.yaml')
MANAGED_CANDIDATE_DIRECTORY = Path('/data/runtime-document-candidates')
MANAGED_CONFIG_CANDIDATE_PATH = MANAGED_CANDIDATE_DIRECTORY / 'config.yaml'
MANAGED_SECRETS_CANDIDATE_PATH = MANAGED_CANDIDATE_DIRECTORY / 'secrets.yaml'
MANAGED_CANDIDATE_LOCK_PATH = MANAGED_CANDIDATE_DIRECTORY / 'candidate.lock'
MAX_CONFIG_DIFF_ENTRIES = 200
MAX_CONFIG_DIFF_OUTPUT_BYTES = 65536
MAX_CONFIG_DIFF_PATH_BYTES = 512
MAX_CONFIG_DIFF_VALUE_BYTES = 512
MAX_CONFIG_DIFF_ENTRY_BYTES = 1024
_HASH = re.compile(r'^[0-9a-f]{64}$')
_CANDIDATE_TEMPORARY = re.compile(
r'^\.(?:config|secrets)\.yaml\.[0-9a-f]{24}\.tmp$'
)
_SENSITIVE_PATH = re.compile(
r'(?:pass(?:word)?|token|secret|credential|auth|cookie)', re.IGNORECASE,
)
_SENSITIVE_CONFIG_FIELDS = frozenset({
'dashboard_db_url', 'database_url', 'edge_marker', 'postgres_dsn',
})
_MISSING = object()
@dataclass(frozen=True)
class ManagedRuntimeDocuments:
config: dict
secrets: dict | None
config_sha256: str
secrets_sha256: str | None
@dataclass(frozen=True)
class RuntimeDocumentIdentity:
sha256: str
byte_count: int
present: bool
@dataclass(frozen=True)
class ManagedRuntimeDocumentState:
active_config: RuntimeDocumentIdentity
active_secrets: RuntimeDocumentIdentity
candidate_config: RuntimeDocumentIdentity
candidate_secrets: RuntimeDocumentIdentity
@dataclass(frozen=True)
class ConfigDiffEntry:
path: str
change: str
before: str | None
after: str | None
value_redacted: bool
value_truncated: bool
@dataclass(frozen=True)
class ConfigDiff:
entries: tuple[ConfigDiffEntry, ...]
truncated: bool
format_only_changed: bool
output_bytes: int
@dataclass(frozen=True)
class SecretsRedactedDiff:
document_changed: bool
semantic_changed: bool
pools_before: int
pools_after: int
pools_added: int
pools_removed: int
pools_changed: int
entries_before: int
entries_after: int
entries_added: int
entries_removed: int
pools_reordered: int
usernames_added: int
usernames_removed: int
usernames_changed: int
tokens_changed: int
@dataclass(frozen=True)
class ManagedRuntimeCandidatePreview:
document: str
state: ManagedRuntimeDocumentState
proposed: RuntimeDocumentIdentity
diff: ConfigDiff | SecretsRedactedDiff
@dataclass(frozen=True)
class ManagedRuntimeCandidateRevision:
document: str
before: ManagedRuntimeDocumentState
after: ManagedRuntimeDocumentState
proposed: RuntimeDocumentIdentity
created: bool
content_changed: bool
written: bool
diff: ConfigDiff | SecretsRedactedDiff
@dataclass(frozen=True)
class ManagedRuntimeApplyVerification:
action: str
state: ManagedRuntimeDocumentState
config_source: str
secrets_source: str
effective_config_sha256: str
effective_secrets_sha256: str
@dataclass(frozen=True)
class ManagedRuntimeEditorDocument:
document: str
source: str
text: str = field(repr=False)
state: ManagedRuntimeDocumentState
selected: RuntimeDocumentIdentity
@dataclass(frozen=True)
class _CandidateSnapshot:
active_config: bytes
active_secrets: bytes
candidate_config: bytes | None
candidate_secrets: bytes | None
state: ManagedRuntimeDocumentState
def _error_details(exc, default_document=None):
return (
exc.category,
exc.line,
exc.column,
exc.document or default_document,
exc.path,
)
def _read_private_document(path, max_bytes, document):
failed = False
payload = None
descriptor = None
try:
checked = require_private_file(os.fspath(path))
flags = os.O_RDONLY
if hasattr(os, 'O_BINARY'):
flags |= os.O_BINARY
if hasattr(os, 'O_NOFOLLOW'):
flags |= os.O_NOFOLLOW
descriptor = os.open(checked, flags)
before = os.fstat(descriptor)
if not stat.S_ISREG(before.st_mode) or before.st_nlink != 1:
raise OSError('private document identity is invalid')
with os.fdopen(descriptor, 'rb') as handle:
descriptor = None
payload = handle.read(max_bytes + 1)
after = os.fstat(handle.fileno())
require_private_file(checked)
current = os.stat(checked, follow_symlinks=False)
identity = lambda value: (value.st_dev, value.st_ino)
if (
identity(before) != identity(after)
or identity(after) != identity(current)
or after.st_nlink != 1
or current.st_nlink != 1
or before.st_size != after.st_size
or after.st_size != current.st_size
or getattr(before, 'st_mtime_ns', None) != getattr(after, 'st_mtime_ns', None)
or getattr(after, 'st_mtime_ns', None) != getattr(current, 'st_mtime_ns', None)
or getattr(before, 'st_ctime_ns', None) != getattr(after, 'st_ctime_ns', None)
or (
os.name != 'nt'
and getattr(after, 'st_ctime_ns', None)
!= getattr(current, 'st_ctime_ns', None)
)
):
raise OSError('private document changed while it was being read')
except Exception:
failed = True
except BaseException:
payload = None
raise
finally:
if descriptor is not None:
os.close(descriptor)
if failed:
return None, ('schema', None, None, document, 'root')
if len(payload) > max_bytes:
return None, ('size', None, None, document, None)
return payload, None
def _read_package_manifest(path):
if os.name == 'nt':
return _read_private_document(
path, MAX_WORKER_PACKAGE_MANIFEST_BYTES, 'package_capabilities',
)
try:
return read_stable_root_file(
path, MAX_WORKER_PACKAGE_MANIFEST_BYTES,
MANAGED_WORKER_PACKAGE_DIRECTORY,
), None
except Exception:
return None, ('schema', None, None, 'package_capabilities', 'root')
def _package_capability_evidence(config):
profiles = evidence = profile_name = profile = reference = None
resolved = payload = failure = manifest = None
try:
try:
profiles = config['supervisor']['worker_api']['compatibility_profiles']
except Exception:
return None, None
if type(profiles) is not dict:
return None, None
evidence = {}
for profile_name, profile in profiles.items():
if type(profile) is not dict:
return None, None
reference = profile.get('package_manifest')
resolved = _resolve_package_manifest_path(config, reference)
if resolved is None:
return None, None
try:
payload, failure = _read_package_manifest(resolved)
if failure is not None:
return None, None
manifest = load_worker_package_manifest_bytes(payload)
except Exception:
return None, None
evidence[profile_name] = {
'package_manifest': reference,
'capabilities': manifest['capabilities'],
}
return evidence, True
finally:
config = profiles = evidence = profile_name = profile = reference = None
resolved = payload = failure = manifest = None
def _validate_managed_runtime_files_inner(config_path, secrets_bytes):
config_payload = None
template_payload = None
active_secrets_payload = None
config = None
package_capabilities = None
validated = None
try:
config_payload, failure = _read_private_document(
config_path, MAX_CONFIG_DOCUMENT_BYTES, 'config',
)
if failure is not None:
return None, failure
template_payload, failure = _read_private_document(
MANAGED_TEMPLATE_PATH, MAX_CONFIG_DOCUMENT_BYTES, 'schema',
)
if failure is not None:
return None, failure
if secrets_bytes is None:
active_secrets_payload, failure = _read_private_document(
MANAGED_SECRETS_PATH, MAX_SECRETS_DOCUMENT_BYTES, 'secrets',
)
if failure is not None:
return None, failure
else:
active_secrets_payload = secrets_bytes
config = load_yaml_document(
config_payload, max_bytes=MAX_CONFIG_DOCUMENT_BYTES,
)
package_capabilities, ready = _package_capability_evidence(config)
if ready is None:
return None, ('capability', None, None, 'package_capabilities', 'root')
validated = preview_runtime_documents(
config_payload,
active_secrets_payload,
config_template_payload=template_payload,
package_capabilities=package_capabilities,
)
return ManagedRuntimeDocuments(
config=validated.config,
secrets=validated.secrets,
config_sha256=hashlib.sha256(config_payload).hexdigest(),
secrets_sha256=hashlib.sha256(active_secrets_payload).hexdigest(),
), None
except RuntimeDocumentError as exc:
return None, _error_details(exc, 'config')
except Exception:
return None, ('schema', None, None, 'schema', 'root')
finally:
secrets_bytes = None
config_payload = None
template_payload = None
active_secrets_payload = None
config = None
package_capabilities = None
validated = None
def validate_managed_runtime_files(config_path, *, secrets_bytes=None):
validated = failure = None
try:
validated, failure = _validate_managed_runtime_files_inner(
config_path, secrets_bytes,
)
finally:
config_path = None
secrets_bytes = None
if failure is not None:
raise RuntimeDocumentError(
failure[0], failure[1], failure[2],
document=failure[3], path=failure[4],
)
return validated
def _load_managed_runtime_config_inner(config_path):
payload = config = failure = None
try:
payload, failure = _read_private_document(
config_path, MAX_CONFIG_DOCUMENT_BYTES, 'config',
)
if failure is not None:
return None, failure
config = load_yaml_document(payload, max_bytes=MAX_CONFIG_DOCUMENT_BYTES)
if type(config) is not dict:
return None, ('mapping_root', None, None, 'config', 'root')
return ManagedRuntimeDocuments(
config=config,
secrets=None,
config_sha256=hashlib.sha256(payload).hexdigest(),
secrets_sha256=None,
), None
except RuntimeDocumentError as exc:
return None, _error_details(exc, 'config')
except Exception:
return None, ('schema', None, None, 'config', 'root')
finally:
payload = config = failure = None
def load_managed_runtime_config(config_path):
loaded, failure = _load_managed_runtime_config_inner(config_path)
config_path = None
if failure is not None:
raise RuntimeDocumentError(
failure[0], failure[1], failure[2],
document=failure[3], path=failure[4],
)
return loaded
def _candidate_failure(category='schema', document='schema', path='root'):
return category, None, None, document, path
def _document_identity(payload, present=True):
try:
return RuntimeDocumentIdentity(
sha256=hashlib.sha256(payload).hexdigest(),
byte_count=len(payload),
present=present,
)
finally:
payload = None
def _read_optional_candidate(path, max_bytes, document):
if not os.path.lexists(path):
return None, None
return _read_private_document(path, max_bytes, document)
def _read_candidate_snapshot(config_path):
active_config = active_secrets = None
candidate_config = candidate_secrets = None
try:
active_config, failure = _read_private_document(
config_path, MAX_CONFIG_DOCUMENT_BYTES, 'config',
)
if failure is not None:
return None, failure
active_secrets, failure = _read_private_document(
MANAGED_SECRETS_PATH, MAX_SECRETS_DOCUMENT_BYTES, 'secrets',
)
if failure is not None:
return None, failure
candidate_config, failure = _read_optional_candidate(
MANAGED_CONFIG_CANDIDATE_PATH, MAX_CONFIG_DOCUMENT_BYTES, 'config',
)
if failure is not None:
return None, failure
candidate_secrets, failure = _read_optional_candidate(
MANAGED_SECRETS_CANDIDATE_PATH, MAX_SECRETS_DOCUMENT_BYTES, 'secrets',
)
if failure is not None:
return None, failure
state = ManagedRuntimeDocumentState(
active_config=_document_identity(active_config),
active_secrets=_document_identity(active_secrets),
candidate_config=_document_identity(
candidate_config if candidate_config is not None else active_config,
candidate_config is not None,
),
candidate_secrets=_document_identity(
candidate_secrets if candidate_secrets is not None else active_secrets,
candidate_secrets is not None,
),
)
return _CandidateSnapshot(
active_config=active_config,
active_secrets=active_secrets,
candidate_config=candidate_config,
candidate_secrets=candidate_secrets,
state=state,
), None
except Exception:
return None, _candidate_failure()
except BaseException:
active_config = active_secrets = None
candidate_config = candidate_secrets = None
raise
def _validate_payload_pair(config_payload, secrets_payload):
template_payload = config = package_capabilities = validated = None
try:
template_payload, failure = _read_private_document(
MANAGED_TEMPLATE_PATH, MAX_CONFIG_DOCUMENT_BYTES, 'schema',
)
if failure is not None:
return None, failure
config = load_yaml_document(
config_payload, max_bytes=MAX_CONFIG_DOCUMENT_BYTES,
)
package_capabilities, ready = _package_capability_evidence(config)
if ready is None:
return None, _candidate_failure(
'capability', 'package_capabilities', 'root',
)
validated = preview_runtime_documents(
config_payload,
secrets_payload,
config_template_payload=template_payload,
package_capabilities=package_capabilities,
)
return validated, None
except RuntimeDocumentError as exc:
return None, _error_details(exc, 'config')
except Exception:
return None, _candidate_failure()
finally:
config_payload = None
secrets_payload = None
template_payload = None
config = None
package_capabilities = None
validated = None
def _prepare_candidate_store():
existed = os.path.lexists(MANAGED_CANDIDATE_DIRECTORY)
require_private_directory(MANAGED_CANDIDATE_DIRECTORY, create=True)
if not existed:
fsync_directory(Path(MANAGED_CANDIDATE_DIRECTORY).parent)
def _verify_candidate_lock():
checked = require_private_file(MANAGED_CANDIDATE_LOCK_PATH)
details = os.stat(checked, follow_symlinks=False)
if not stat.S_ISREG(details.st_mode) or details.st_nlink != 1:
raise OSError('candidate lock identity is invalid')
if os.name != 'nt' and stat.S_IMODE(details.st_mode) != 0o600:
raise OSError('candidate lock mode is invalid')
def _cleanup_candidate_temporaries():
removed = False
with os.scandir(MANAGED_CANDIDATE_DIRECTORY) as entries:
for entry in entries:
if _CANDIDATE_TEMPORARY.fullmatch(entry.name) is None:
continue
details = os.stat(entry.path, follow_symlinks=False)
if (
not stat.S_ISREG(details.st_mode)
or details.st_nlink != 1
or (os.name != 'nt' and stat.S_IMODE(details.st_mode) != 0o600)
):
raise OSError('candidate temporary file identity is invalid')
require_private_file(entry.path)
os.unlink(entry.path)
removed = True
if removed:
fsync_directory(MANAGED_CANDIDATE_DIRECTORY)
def _valid_expected_hash(value):
return type(value) is str and _HASH.fullmatch(value) is not None
def _same_hash(actual, expected):
return hmac.compare_digest(actual, expected)
def _truncate_text(value, max_bytes):
encoded = None
shortened = None
try:
encoded = value.encode('utf-8')
if len(encoded) <= max_bytes:
return value, False
shortened = encoded[:max(0, max_bytes - 3)].decode('utf-8', 'ignore') + '...'
return shortened, True
finally:
value = None
encoded = None
shortened = None
def _render_diff_value(value):
try:
return json.dumps(
value, ensure_ascii=True, sort_keys=True, separators=(',', ':'),
)
finally:
value = None
def _config_diff(before, after, before_hash, after_hash):
entries = []
output_bytes = 2 # JSON list delimiters.
truncated = False
stack = [('', before, after)]
old = new = old_text = new_text = entry = None
try:
while stack and not truncated:
path, old, new = stack.pop()
if old == new:
continue
old_empty_container = type(old) in (dict, list) and not old
new_empty_container = type(new) in (dict, list) and not new
leaf_container_change = (
old_empty_container
and (new is _MISSING or type(new) is not type(old))
) or (
new_empty_container
and (old is _MISSING or type(old) is not type(new))
)
if not leaf_container_change and type(old) is dict and type(new) is dict:
keys = sorted(set(old) | set(new), reverse=True)
for key in keys:
child = f'{path}.{key}' if path else str(key)
stack.append((
child, old.get(key, _MISSING), new.get(key, _MISSING),
))
continue
if not leaf_container_change and type(old) is list and type(new) is list:
for index in range(max(len(old), len(new)) - 1, -1, -1):
stack.append((
f'{path}[{index}]',
old[index] if index < len(old) else _MISSING,
new[index] if index < len(new) else _MISSING,
))
continue
if (
not leaf_container_change
and old is _MISSING
and type(new) in (dict, list)
and new
):
stack.append((path, {} if type(new) is dict else [], new))
continue
if (
not leaf_container_change
and new is _MISSING
and type(old) in (dict, list)
and old
):
stack.append((path, old, {} if type(old) is dict else []))
continue
if not leaf_container_change and (
type(old) in (dict, list) or type(new) in (dict, list)
):
if new is not _MISSING:
stack.append((
path,
{} if type(new) is dict else [] if type(new) is list else _MISSING,
new,
))
if old is not _MISSING:
stack.append((
path,
old,
{} if type(old) is dict else [] if type(old) is list else _MISSING,
))
continue
path = path or 'root'
if len(entries) >= MAX_CONFIG_DIFF_ENTRIES:
truncated = True
continue
if len(path.encode('utf-8')) > MAX_CONFIG_DIFF_PATH_BYTES:
truncated = True
continue
lowered = '.' + path.lower() + '.'
field_name = re.split(r'[.\[]', path)[-1].rstrip(']').lower()
redacted = (
'.env.' in lowered
or _SENSITIVE_PATH.search(path) is not None
or field_name in _SENSITIVE_CONFIG_FIELDS
or type(old) is str
or type(new) is str
)
value_truncated = False
if redacted:
old_text = None if old is _MISSING else '[redacted]'
new_text = None if new is _MISSING else '[redacted]'
else:
old_text = None if old is _MISSING else _render_diff_value(old)
new_text = None if new is _MISSING else _render_diff_value(new)
if old_text is not None:
old_text, cut = _truncate_text(
old_text, MAX_CONFIG_DIFF_VALUE_BYTES,
)
value_truncated = value_truncated or cut
if new_text is not None:
new_text, cut = _truncate_text(
new_text, MAX_CONFIG_DIFF_VALUE_BYTES,
)
value_truncated = value_truncated or cut
change = (
'added' if old is _MISSING
else 'removed' if new is _MISSING
else 'changed'
)
entry = ConfigDiffEntry(
path=path,
change=change,
before=old_text,
after=new_text,
value_redacted=redacted,
value_truncated=value_truncated,
)
entry_bytes = len(json.dumps(
entry.__dict__, ensure_ascii=True, sort_keys=True,
separators=(',', ':'),
).encode('utf-8'))
entry_output_bytes = entry_bytes + (1 if entries else 0)
if (
entry_bytes > MAX_CONFIG_DIFF_ENTRY_BYTES
or output_bytes + entry_output_bytes > MAX_CONFIG_DIFF_OUTPUT_BYTES
):
truncated = True
continue
entries.append(entry)
output_bytes += entry_output_bytes
return ConfigDiff(
entries=tuple(entries),
truncated=truncated,
format_only_changed=(
before_hash != after_hash and before == after and not entries
),
output_bytes=output_bytes,
)
finally:
before = after = old = new = None
old_text = new_text = entry = None
stack = None
entries = None
def _secrets_diff(before, after, before_hash, after_hash):
before_pools = after_pools = None
old_entries = new_entries = None
old_username = new_username = None
entry = entries = None
before_names = after_names = common_pools = None
old_names = new_names = None
pool_name = name = None
try:
before_pools = before.get('auth_pools', {})
after_pools = after.get('auth_pools', {})
before_names = set(before_pools)
after_names = set(after_pools)
common_pools = before_names & after_names
entries_before = entries_after = 0
for entries in before_pools.values():
entries_before += len(entries)
for entries in after_pools.values():
entries_after += len(entries)
entries_added = entries_removed = 0
usernames_added = usernames_removed = usernames_changed = 0
tokens_changed = 0
for pool_name in before_names | after_names:
old_entries = {}
for entry in before_pools.get(pool_name, []):
old_entries[entry['name']] = entry
new_entries = {}
for entry in after_pools.get(pool_name, []):
new_entries[entry['name']] = entry
old_names = set(old_entries)
new_names = set(new_entries)
entries_added += len(new_names - old_names)
entries_removed += len(old_names - new_names)
for name in new_names - old_names:
usernames_added += int('username' in new_entries[name])
for name in old_names - new_names:
usernames_removed += int('username' in old_entries[name])
for name in old_names & new_names:
old_username = old_entries[name].get('username', _MISSING)
new_username = new_entries[name].get('username', _MISSING)
if old_username is _MISSING and new_username is not _MISSING:
usernames_added += 1
elif old_username is not _MISSING and new_username is _MISSING:
usernames_removed += 1
elif old_username != new_username:
usernames_changed += 1
if old_entries[name].get('token') != new_entries[name].get('token'):
tokens_changed += 1
pools_changed = 0
for name in common_pools:
pools_changed += int(before_pools[name] != after_pools[name])
return SecretsRedactedDiff(
document_changed=before_hash != after_hash,
semantic_changed=before != after,
pools_before=len(before_pools),
pools_after=len(after_pools),
pools_added=len(after_names - before_names),
pools_removed=len(before_names - after_names),
pools_changed=pools_changed,
entries_before=entries_before,
entries_after=entries_after,
entries_added=entries_added,
entries_removed=entries_removed,
pools_reordered=int(
before_names == after_names
and list(before_pools) != list(after_pools)
),
usernames_added=usernames_added,
usernames_removed=usernames_removed,
usernames_changed=usernames_changed,
tokens_changed=tokens_changed,
)
finally:
before = after = None
before_pools = after_pools = None
old_entries = new_entries = None
old_username = new_username = entry = entries = None
before_names = after_names = common_pools = None
old_names = new_names = None
pool_name = name = None
def _candidate_diff(document, active, proposed, active_payload, proposed_payload):
try:
active_hash = hashlib.sha256(active_payload).hexdigest()
proposed_hash = hashlib.sha256(proposed_payload).hexdigest()
if document == 'config':
return _config_diff(
active.config, proposed.config, active_hash, proposed_hash,
)
return _secrets_diff(
active.secrets, proposed.secrets, active_hash, proposed_hash,
)
finally:
active = proposed = None
active_payload = proposed_payload = None
def _proposed_pair(snapshot, document, candidate_bytes):
try:
if document == 'config':
return (
candidate_bytes,
snapshot.candidate_secrets
if snapshot.candidate_secrets is not None
else snapshot.active_secrets,
)
return (
snapshot.candidate_config
if snapshot.candidate_config is not None
else snapshot.active_config,
candidate_bytes,
)
finally:
snapshot = None
candidate_bytes = None
def _stage_candidate(path, payload, max_bytes):
temporary = None
descriptor = None
stored = None
try:
for _attempt in range(16):
temporary = Path(path).with_name(
f'.{Path(path).name}.{os.urandom(12).hex()}.tmp'
)
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
if hasattr(os, 'O_BINARY'):
flags |= os.O_BINARY
if hasattr(os, 'O_NOFOLLOW'):
flags |= os.O_NOFOLLOW
try:
descriptor = os.open(temporary, flags, 0o600)
break
except FileExistsError:
temporary = None
if descriptor is None or temporary is None:
raise OSError('candidate temporary file could not be created')
with os.fdopen(descriptor, 'wb') as handle:
descriptor = None
before = os.fstat(handle.fileno())
if not stat.S_ISREG(before.st_mode) or before.st_nlink != 1:
raise OSError('candidate temporary file identity is invalid')
handle.write(payload)
handle.flush()
os.fsync(handle.fileno())
after = os.fstat(handle.fileno())
if (
not stat.S_ISREG(after.st_mode)
or after.st_nlink != 1
or after.st_size != len(payload)
or (os.name != 'nt' and stat.S_IMODE(after.st_mode) != 0o600)
):
raise OSError('candidate temporary file metadata is invalid')
harden_private_file(temporary)
stored, failure = _read_private_document(
path=temporary, max_bytes=max_bytes, document='schema',
)
if failure is not None or not hmac.compare_digest(
hashlib.sha256(stored).digest(), hashlib.sha256(payload).digest(),
):
raise OSError('candidate temporary file verification failed')
return temporary
except BaseException:
if temporary is not None:
try:
os.unlink(temporary)
except OSError:
pass
raise
finally:
if descriptor is not None:
os.close(descriptor)
payload = None
stored = None
def _rollback_candidate(path, previous_payload, expected_payload, max_bytes, document):
current = temporary = None
try:
current, failure = _read_private_document(path, max_bytes, document)
if failure is not None or not hmac.compare_digest(
hashlib.sha256(current).digest(),
hashlib.sha256(expected_payload).digest(),
):
return False
if previous_payload is None:
os.unlink(path)
fsync_directory(MANAGED_CANDIDATE_DIRECTORY)
return not os.path.lexists(path)
temporary = _stage_candidate(path, previous_payload, max_bytes)
durable_replace(temporary, path)
temporary = None
failure = _verify_final_candidate(
path, previous_payload, max_bytes, document,
)
if failure is not None:
return False
fsync_directory(MANAGED_CANDIDATE_DIRECTORY)
return True
except Exception:
return False
finally:
try:
if temporary is not None:
try:
os.unlink(temporary)
except OSError:
pass
finally:
current = temporary = failure = None
previous_payload = expected_payload = None
def _rollback_committed_candidate(
path, previous_payload, expected_payload, max_bytes, document,
):
try:
with PrivateFileLock(MANAGED_CANDIDATE_LOCK_PATH):
_verify_candidate_lock()
return _rollback_candidate(
path, previous_payload, expected_payload, max_bytes, document,
)
except BaseException:
return False
finally:
path = previous_payload = expected_payload = None
max_bytes = document = None
def _verify_final_candidate(path, expected_payload, max_bytes, document):
stored = None
try:
stored, failure = _read_private_document(path, max_bytes, document)
if failure is not None:
return failure
details = os.stat(path, follow_symlinks=False)
if (
not stat.S_ISREG(details.st_mode)
or details.st_nlink != 1
or (os.name != 'nt' and stat.S_IMODE(details.st_mode) != 0o600)
or len(stored) != len(expected_payload)
or not hmac.compare_digest(
hashlib.sha256(stored).digest(),
hashlib.sha256(expected_payload).digest(),
)
):
return _candidate_failure('schema', document, 'root')
return None
finally:
stored = None
expected_payload = None
def _load_editor_document_inner(config_path, document):
snapshot = payload = text = None
try:
if document not in ('config', 'secrets'):
return None, _candidate_failure('invalid_input')
_prepare_candidate_store()
with PrivateFileLock(MANAGED_CANDIDATE_LOCK_PATH):
_verify_candidate_lock()
_cleanup_candidate_temporaries()
snapshot, failure = _read_candidate_snapshot(config_path)
if failure is not None:
return None, failure
if document == 'config':
present = snapshot.state.candidate_config.present
payload = snapshot.candidate_config if present else snapshot.active_config
selected = snapshot.state.candidate_config
else:
present = snapshot.state.candidate_secrets.present
payload = snapshot.candidate_secrets if present else snapshot.active_secrets
selected = snapshot.state.candidate_secrets
try:
text = payload.decode('utf-8', errors='strict')
except UnicodeDecodeError:
return None, _candidate_failure('encoding', document, 'root')
return ManagedRuntimeEditorDocument(
document=document,
source='candidate' if present else 'active',
text=text,
state=snapshot.state,
selected=selected,
), None
except Exception:
return None, _candidate_failure()
finally:
snapshot = payload = text = None
def load_managed_runtime_editor_document(config_path, document):
editor = failure = None
try:
editor, failure = _load_editor_document_inner(config_path, document)
finally:
config_path = document = None
if failure is not None:
raise RuntimeDocumentError(
failure[0], failure[1], failure[2],
document=failure[3], path=failure[4],
)
return editor
def _preview_candidate_inner(config_path, document, candidate_bytes):
snapshot = active_validated = proposed_validated = None
proposed_config = proposed_secrets = None
active_payload = None
try:
if document not in ('config', 'secrets'):
return None, _candidate_failure('invalid_input')
if type(candidate_bytes) is not bytes:
return None, _candidate_failure('invalid_input', document, 'root')
_prepare_candidate_store()
with PrivateFileLock(MANAGED_CANDIDATE_LOCK_PATH):
_verify_candidate_lock()
_cleanup_candidate_temporaries()
snapshot, failure = _read_candidate_snapshot(config_path)
if failure is not None:
return None, failure
active_validated, failure = _validate_payload_pair(
snapshot.active_config, snapshot.active_secrets,
)
if failure is not None:
return None, failure
proposed_config, proposed_secrets = _proposed_pair(
snapshot, document, candidate_bytes,
)
proposed_validated, failure = _validate_payload_pair(
proposed_config, proposed_secrets,
)
if failure is not None:
return None, failure
active_payload = (
snapshot.active_config if document == 'config'
else snapshot.active_secrets
)
return ManagedRuntimeCandidatePreview(
document=document,
state=snapshot.state,
proposed=_document_identity(candidate_bytes),
diff=_candidate_diff(
document,
active_validated,
proposed_validated,
active_payload,
candidate_bytes,
),
), None
except Exception:
return None, _candidate_failure()
finally:
candidate_bytes = None
snapshot = None
active_validated = None
proposed_validated = None
proposed_config = None
proposed_secrets = None
active_payload = None
def preview_managed_runtime_candidate(config_path, document, candidate_bytes):
preview = failure = None
try:
preview, failure = _preview_candidate_inner(
config_path, document, candidate_bytes,
)
finally:
config_path = None
document = None
candidate_bytes = None
if failure is not None:
raise RuntimeDocumentError(
failure[0], failure[1], failure[2],
document=failure[3], path=failure[4],
)
return preview
def _save_candidate_inner(
config_path,
document,
candidate_bytes,
expected_hashes,
):
snapshot = current = after = None
active_validated = proposed_validated = None
proposed_config = proposed_secrets = None
previous_payload = expected_after = None
temporary = None
committed = False
try:
if document not in ('config', 'secrets'):
return None, _candidate_failure('invalid_input')
if type(candidate_bytes) is not bytes:
return None, _candidate_failure('invalid_input', document, 'root')
if not all(_valid_expected_hash(value) for value in expected_hashes):
return None, _candidate_failure('reference', document, 'revision')
_prepare_candidate_store()
with PrivateFileLock(MANAGED_CANDIDATE_LOCK_PATH):
_verify_candidate_lock()
_cleanup_candidate_temporaries()
snapshot, failure = _read_candidate_snapshot(config_path)
if failure is not None:
return None, failure
actual_hashes = (
snapshot.state.active_config.sha256,
snapshot.state.active_secrets.sha256,
snapshot.state.candidate_config.sha256,
snapshot.state.candidate_secrets.sha256,
)
if not all(
_same_hash(actual, expected)
for actual, expected in zip(actual_hashes, expected_hashes)
):
return None, _candidate_failure('reference', document, 'revision')
active_validated, failure = _validate_payload_pair(
snapshot.active_config, snapshot.active_secrets,
)
if failure is not None:
return None, failure
proposed_config, proposed_secrets = _proposed_pair(
snapshot, document, candidate_bytes,
)
proposed_validated, failure = _validate_payload_pair(
proposed_config, proposed_secrets,
)
if failure is not None:
return None, failure
selected_identity = (
snapshot.state.candidate_config if document == 'config'
else snapshot.state.candidate_secrets
)
proposed_identity = _document_identity(candidate_bytes)
selected_present = selected_identity.present
content_changed = not _same_hash(
selected_identity.sha256, proposed_identity.sha256,
) or selected_identity.byte_count != proposed_identity.byte_count
diff = _candidate_diff(
document,
active_validated,
proposed_validated,
snapshot.active_config if document == 'config' else snapshot.active_secrets,
candidate_bytes,
)
if selected_present and not content_changed:
return ManagedRuntimeCandidateRevision(
document=document,
before=snapshot.state,
after=snapshot.state,
proposed=proposed_identity,
created=False,
content_changed=False,
written=False,
diff=diff,
), None
path = (
MANAGED_CONFIG_CANDIDATE_PATH
if document == 'config'
else MANAGED_SECRETS_CANDIDATE_PATH
)
max_bytes = (
MAX_CONFIG_DOCUMENT_BYTES
if document == 'config'
else MAX_SECRETS_DOCUMENT_BYTES
)
previous_payload = (
snapshot.candidate_config
if document == 'config'
else snapshot.candidate_secrets
)
temporary = _stage_candidate(path, candidate_bytes, max_bytes)
current, failure = _read_candidate_snapshot(config_path)
if failure is not None:
return None, failure
if current.state != snapshot.state:
return None, _candidate_failure('reference', document, 'revision')
committed = True
durable_replace(temporary, path)
temporary = None
failure = _verify_final_candidate(
path, candidate_bytes, max_bytes, document,
)
if failure is not None:
rolled_back = _rollback_candidate(
path, previous_payload, candidate_bytes, max_bytes, document,
)
committed = not rolled_back
return None, failure if rolled_back else _candidate_failure()
try:
fsync_directory(MANAGED_CANDIDATE_DIRECTORY)
except Exception:
rolled_back = _rollback_candidate(
path, previous_payload, candidate_bytes, max_bytes, document,
)
committed = not rolled_back
return None, _candidate_failure()
after, failure = _read_candidate_snapshot(config_path)
if failure is not None:
rolled_back = _rollback_candidate(
path, previous_payload, candidate_bytes, max_bytes, document,
)
committed = not rolled_back
return None, failure if rolled_back else _candidate_failure()
expected_after = ManagedRuntimeDocumentState(
active_config=snapshot.state.active_config,
active_secrets=snapshot.state.active_secrets,
candidate_config=(
proposed_identity
if document == 'config'
else snapshot.state.candidate_config
),
candidate_secrets=(
proposed_identity
if document == 'secrets'
else snapshot.state.candidate_secrets
),
)
if after.state != expected_after:
rolled_back = _rollback_candidate(
path, previous_payload, candidate_bytes, max_bytes, document,
)
committed = not rolled_back
return None, _candidate_failure(
'reference' if rolled_back else 'schema',
document,
'revision' if rolled_back else 'root',
)
committed = False
return ManagedRuntimeCandidateRevision(
document=document,
before=snapshot.state,
after=after.state,
proposed=proposed_identity,
created=not selected_present,
content_changed=content_changed,
written=True,
diff=diff,
), None
except Exception:
if committed:
_rollback_committed_candidate(
path, previous_payload, candidate_bytes, max_bytes, document,
)
return None, _candidate_failure()
except BaseException:
if committed:
_rollback_committed_candidate(
path, previous_payload, candidate_bytes, max_bytes, document,
)
raise
finally:
if temporary is not None:
try:
os.unlink(temporary)
except OSError:
pass
candidate_bytes = None
snapshot = None
current = None
after = None
active_validated = None
proposed_validated = None
proposed_config = None
proposed_secrets = None
previous_payload = None
expected_after = None
def save_managed_runtime_candidate(
config_path,
document,
candidate_bytes,
*,
expected_active_config_sha256,
expected_active_secrets_sha256,
expected_candidate_config_sha256,
expected_candidate_secrets_sha256,
):
revision = failure = None
try:
revision, failure = _save_candidate_inner(
config_path,
document,
candidate_bytes,
(
expected_active_config_sha256,
expected_active_secrets_sha256,
expected_candidate_config_sha256,
expected_candidate_secrets_sha256,
),
)
finally:
config_path = None
document = None
candidate_bytes = None
expected_active_config_sha256 = None
expected_active_secrets_sha256 = None
expected_candidate_config_sha256 = None
expected_candidate_secrets_sha256 = None
if failure is not None:
raise RuntimeDocumentError(
failure[0], failure[1], failure[2],
document=failure[3], path=failure[4],
)
return revision
def _verify_candidates_inner(
config_path,
action,
expected_active_config_sha256,
expected_active_secrets_sha256,
expected_candidate_config_sha256,
expected_candidate_secrets_sha256,
):
snapshot = validated = None
config_payload = secrets_payload = None
try:
if action not in ('apply-config', 'apply-secrets', 'apply-both'):
return None, _candidate_failure('invalid_input')
if not _valid_expected_hash(expected_active_config_sha256) or not _valid_expected_hash(
expected_active_secrets_sha256
):
return None, _candidate_failure('reference', 'schema', 'revision')
required_config = action in ('apply-config', 'apply-both')
required_secrets = action in ('apply-secrets', 'apply-both')
if (
required_config != (expected_candidate_config_sha256 is not None)
or required_secrets != (expected_candidate_secrets_sha256 is not None)
or (
expected_candidate_config_sha256 is not None
and not _valid_expected_hash(expected_candidate_config_sha256)
)
or (
expected_candidate_secrets_sha256 is not None
and not _valid_expected_hash(expected_candidate_secrets_sha256)
)
):
return None, _candidate_failure('reference', 'schema', 'revision')
_prepare_candidate_store()
with PrivateFileLock(MANAGED_CANDIDATE_LOCK_PATH):
_verify_candidate_lock()
_cleanup_candidate_temporaries()
snapshot, failure = _read_candidate_snapshot(config_path)
if failure is not None:
return None, failure
if not _same_hash(
snapshot.state.active_config.sha256,
expected_active_config_sha256,
) or not _same_hash(
snapshot.state.active_secrets.sha256,
expected_active_secrets_sha256,
):
return None, _candidate_failure('reference', 'schema', 'revision')
if required_config:
if not snapshot.state.candidate_config.present or not _same_hash(
snapshot.state.candidate_config.sha256,
expected_candidate_config_sha256,
):
return None, _candidate_failure('reference', 'config', 'revision')
config_payload = snapshot.candidate_config
else:
config_payload = snapshot.active_config
if required_secrets:
if not snapshot.state.candidate_secrets.present or not _same_hash(
snapshot.state.candidate_secrets.sha256,
expected_candidate_secrets_sha256,
):
return None, _candidate_failure('reference', 'secrets', 'revision')
secrets_payload = snapshot.candidate_secrets
else:
secrets_payload = snapshot.active_secrets
validated, failure = _validate_payload_pair(config_payload, secrets_payload)
if failure is not None:
return None, failure
return ManagedRuntimeApplyVerification(
action=action,
state=snapshot.state,
config_source='candidate' if required_config else 'active',
secrets_source='candidate' if required_secrets else 'active',
effective_config_sha256=hashlib.sha256(config_payload).hexdigest(),
effective_secrets_sha256=hashlib.sha256(secrets_payload).hexdigest(),
), None
except Exception:
return None, _candidate_failure()
finally:
snapshot = None
validated = None
config_payload = None
secrets_payload = None
def verify_managed_runtime_candidates(
config_path,
action,
*,
expected_active_config_sha256,
expected_active_secrets_sha256,
expected_candidate_config_sha256=None,
expected_candidate_secrets_sha256=None,
):
verified, failure = _verify_candidates_inner(
config_path,
action,
expected_active_config_sha256,
expected_active_secrets_sha256,
expected_candidate_config_sha256,
expected_candidate_secrets_sha256,
)
config_path = None
action = None
expected_active_config_sha256 = None
expected_active_secrets_sha256 = None
expected_candidate_config_sha256 = None
expected_candidate_secrets_sha256 = None
if failure is not None:
raise RuntimeDocumentError(
failure[0], failure[1], failure[2],
document=failure[3], path=failure[4],
)
return verified