| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159 |
- """Install the two query skills from a verified package; default is a read-only plan.
- Only checksummed skill files are managed. Credentials, employee state, databases,
- gateway configuration and services are never installed or modified here.
- """
- import argparse
- import hashlib
- import json
- import os
- from pathlib import Path
- import shutil
- import tempfile
- NAMES = ('ksf-workshop-runtime', 'ksf-voc-library')
- def digest(path):
- with path.open('rb') as stream:
- result = hashlib.sha256()
- for block in iter(lambda: stream.read(1024 * 1024), b''):
- result.update(block)
- return result.hexdigest()
- def plain(path):
- path = Path(path).absolute()
- for ancestor in (path, *path.parents):
- if ancestor.exists() or ancestor.is_symlink():
- if ancestor.is_symlink() or ancestor.resolve() != ancestor:
- raise ValueError('REDIRECTED_PATH')
- return path
- def snapshot(folder):
- if not folder.exists():
- return {}
- result = {}
- for path in folder.rglob('*'):
- plain(path)
- if path.is_file():
- result[path.relative_to(folder).as_posix()] = digest(path)
- return result
- def contents(folder):
- plain(folder)
- entries = {}
- for line in (folder / 'FILES.sha256').read_text(encoding='utf-8').splitlines():
- sha, rel = line.split(' ', 1)
- parts = Path(rel).parts
- if len(sha) != 64 or any(ch not in '0123456789abcdef' for ch in sha) or not parts or Path(rel).is_absolute() or '..' in parts or rel in entries or rel in ('FILES.sha256', 'credential-binding.json'):
- raise ValueError('INVALID_SKILL_MANIFEST')
- if digest(plain(folder / rel)) != sha:
- raise ValueError('PACKAGE_HASH_MISMATCH')
- entries[rel] = sha
- entries['FILES.sha256'] = digest(folder / 'FILES.sha256')
- if snapshot(folder) != entries:
- raise ValueError('UNEXPECTED_SKILL_FILES')
- return entries
- def install(package, data_root, apply=False):
- package, data_root = plain(package), plain(data_root)
- if package == data_root or package in data_root.parents:
- raise ValueError('NESTED_PACKAGE_AND_DATA_ROOT')
- baseline = json.loads((package / 'baselines.json').read_text(encoding='utf-8'))
- entries, changes, targets = {}, [], {}
- for name in NAMES:
- source = package / 'skills' / name
- target = plain(data_root / 'skills' / name)
- if package == target or package in target.parents or target in package.parents:
- raise ValueError('NESTED_PACKAGE_AND_SKILL')
- wanted = contents(source)
- entries[name], targets[name] = wanted, target
- if target.exists() and not target.is_dir():
- raise ValueError('TARGET_NOT_DIRECTORY')
- actual = {rel: digest(plain(target / rel)) for rel in wanted if (target / rel).is_file()}
- if target.exists() and actual != wanted and actual not in baseline[name]:
- raise ValueError('UNKNOWN_SKILL_BASELINE:' + name)
- if not target.exists() and name == 'ksf-workshop-runtime':
- raise ValueError('WORKSHOP_INSTANCE_NOT_PROVISIONED')
- for rel, sha in wanted.items():
- if actual.get(rel) != sha:
- changes.append((name, rel, actual.get(rel), sha))
- state = data_root / 'ksf-workshop-state'
- binding = targets['ksf-workshop-runtime'] / 'credential-binding.json'
- before_state = snapshot(state)
- before_binding = digest(plain(binding)) if binding.is_file() else None
- result = dict(ok=True, readOnly=not apply, applied=False,
- changed=[name + '/' + rel for name, rel, _, _ in changes],
- filesChecked=sum(len(files) for files in entries.values()),
- databaseWrites=0, databaseChecked=False, networkRequests=0,
- credentialBindingPreserved=True, stateDirectoryPreserved=True,
- gatewayModified=False, discoveryVerified=False, realMessageVerified=False)
- if not apply or not changes:
- return result
- data_root.mkdir(parents=True, exist_ok=True)
- backup = Path(tempfile.mkdtemp(prefix='ksf-query-skills-backup-', dir=data_root))
- os.chmod(backup, 0o700)
- installed = []
- try:
- for name, rel, before, after in changes:
- source, target = package / 'skills' / name / rel, targets[name] / rel
- if (digest(plain(target)) if target.is_file() else None) != before:
- raise ValueError('CONCURRENT_SKILL_CHANGE')
- mode = target.stat().st_mode & 0o777 if target.is_file() else 0o600
- previous = backup / name / rel
- if before:
- previous.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
- shutil.copy2(target, previous)
- target.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
- fd, temporary = tempfile.mkstemp(prefix='.query-skill-', dir=target.parent)
- temporary = Path(temporary)
- try:
- with os.fdopen(fd, 'wb') as stream:
- stream.write(source.read_bytes())
- stream.flush()
- os.fsync(stream.fileno())
- os.chmod(temporary, mode)
- if digest(temporary) != after:
- raise ValueError('STAGED_HASH_MISMATCH')
- if (digest(plain(target)) if target.is_file() else None) != before:
- raise ValueError('CONCURRENT_SKILL_CHANGE')
- os.replace(temporary, target)
- installed.append((target, previous, before, after))
- finally:
- temporary.unlink(missing_ok=True)
- for name, wanted in entries.items():
- if any(digest(plain(targets[name] / rel)) != sha for rel, sha in wanted.items()):
- raise ValueError('INSTALLED_HASH_MISMATCH')
- if snapshot(state) != before_state or (digest(plain(binding)) if binding.is_file() else None) != before_binding:
- raise ValueError('STATE_OR_BINDING_CHANGED')
- except Exception:
- for target, previous, before, after in reversed(installed):
- # Never overwrite a concurrent edit while rolling back our files.
- if target.is_file() and digest(target) == after:
- if before:
- shutil.copy2(previous, target)
- else:
- target.unlink()
- raise
- result.update(applied=True, backup=str(backup), filesystemVerified=True)
- return result
- if __name__ == '__main__':
- parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument('--package', required=True)
- parser.add_argument('--data-root', default='/opt/data')
- mode = parser.add_mutually_exclusive_group()
- mode.add_argument('--check', action='store_true')
- mode.add_argument('--apply', action='store_true')
- args = parser.parse_args()
- try:
- print(json.dumps(install(args.package, args.data_root, args.apply), ensure_ascii=False, indent=2))
- except (ValueError, OSError, KeyError) as error:
- print(json.dumps(dict(ok=False, error=str(error)), ensure_ascii=False))
- raise SystemExit(1)
|