Install verified release bundles with recoverable activation
This commit is contained in:
1 parent
17243464f6
commit
f770147c6d
5 files changed
+1172
-371
No files matched your search
@@ -0,0 +1,568 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build release bundles and apply recoverable, serialized system installations."""
|
||||
import argparse
|
||||
import contextlib
|
||||
import fcntl
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
INVENTORY = json.loads(Path(__file__).with_name('inventory.json').read_text())
|
||||
PREFIX = '/usr/local/lib/ferese'
|
||||
ID = re.compile(r'[A-Za-z0-9][A-Za-z0-9._-]*\Z')
|
||||
|
||||
|
||||
def fail(message):
|
||||
raise ValueError(message)
|
||||
|
||||
|
||||
def release_id(value):
|
||||
if not ID.fullmatch(value):
|
||||
fail(f'Invalid release ID: {value}')
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def digest(path):
|
||||
with path.open('rb') as stream:
|
||||
checksum = hashlib.sha256()
|
||||
for block in iter(lambda: stream.read(1024 * 1024), b''):
|
||||
checksum.update(block)
|
||||
|
||||
return checksum.hexdigest()
|
||||
|
||||
|
||||
def sync_dir(path):
|
||||
fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY)
|
||||
try:
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def atomic_bytes(path, data, mode=0o644):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, temporary = tempfile.mkstemp(prefix='.ferese-', dir=path.parent)
|
||||
try:
|
||||
with os.fdopen(fd, 'wb') as stream:
|
||||
stream.write(data)
|
||||
os.fchmod(stream.fileno(), mode)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
|
||||
os.replace(temporary, path)
|
||||
sync_dir(path.parent)
|
||||
finally:
|
||||
if os.path.lexists(temporary):
|
||||
os.unlink(temporary)
|
||||
|
||||
|
||||
def atomic_copy(path, source, mode):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, temporary = tempfile.mkstemp(prefix='.ferese-', dir=path.parent)
|
||||
try:
|
||||
with os.fdopen(fd, 'wb') as output:
|
||||
source_fd = os.open(source, os.O_RDONLY | os.O_NOFOLLOW)
|
||||
with os.fdopen(source_fd, 'rb') as stream:
|
||||
if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
|
||||
fail(f'Expected regular file: {source}')
|
||||
shutil.copyfileobj(stream, output)
|
||||
|
||||
os.fchmod(output.fileno(), mode)
|
||||
output.flush()
|
||||
os.fsync(output.fileno())
|
||||
|
||||
os.replace(temporary, path)
|
||||
sync_dir(path.parent)
|
||||
finally:
|
||||
if os.path.lexists(temporary):
|
||||
os.unlink(temporary)
|
||||
|
||||
|
||||
def sync_tree(path):
|
||||
# Persist new directory entries as well as file contents before publication.
|
||||
for directory, _, _ in os.walk(path, topdown=False):
|
||||
sync_dir(Path(directory))
|
||||
|
||||
|
||||
def atomic_json(path, data, mode=0o600):
|
||||
atomic_bytes(path, (json.dumps(data, indent=2, sort_keys=True) + '\n').encode(), mode)
|
||||
|
||||
|
||||
def record(rule, name=None):
|
||||
return {key: (value.format(name=name) if key == 'target' else value)
|
||||
for key, value in rule.items() if key != 'source'}
|
||||
|
||||
|
||||
def validate_bundle(bundle):
|
||||
if bundle.is_symlink() or not bundle.is_dir():
|
||||
fail(f'Bundle must be a directory, not a symlink: {bundle}')
|
||||
|
||||
for path in bundle.rglob('*'):
|
||||
if path.is_symlink() or not (path.is_file() or path.is_dir()):
|
||||
fail(f'Unexpected bundle entry: {path}')
|
||||
|
||||
manifest = json.loads((bundle / 'manifest.json').read_text())
|
||||
if manifest.get('format') != 1:
|
||||
fail('Unsupported bundle format')
|
||||
|
||||
release_id(manifest['release'])
|
||||
expected = {rule['target']: record(rule) for rule in INVENTORY['files'] if '*' not in rule['source']}
|
||||
seen = set()
|
||||
for item in manifest['files']:
|
||||
target = item['target']
|
||||
allowed = expected.get(target)
|
||||
if allowed is None and re.fullmatch(r'licenses/theme-[A-Za-z0-9._-]+\.txt', target):
|
||||
allowed = {'target': target, 'mode': '0644'}
|
||||
|
||||
if allowed is None or {k: v for k, v in item.items() if k != 'sha256'} != allowed or target in seen:
|
||||
fail(f'Invalid inventory entry: {target}')
|
||||
|
||||
path = bundle / target
|
||||
if not path.is_file() or digest(path) != item['sha256']:
|
||||
fail(f'Checksum mismatch: {target}')
|
||||
if stat.S_IMODE(path.stat().st_mode) != int(item['mode'], 8):
|
||||
fail(f'Incorrect file mode: {target}')
|
||||
|
||||
seen.add(target)
|
||||
|
||||
actual = {str(path.relative_to(bundle)) for path in bundle.rglob('*') if path.is_file()}
|
||||
if not expected.keys() <= seen or actual != seen | {'manifest.json'}:
|
||||
fail('Bundle is incomplete or contains unlisted files')
|
||||
|
||||
return manifest
|
||||
|
||||
|
||||
def bundle(args):
|
||||
release_id(args.release_id)
|
||||
source = args.source_root.resolve()
|
||||
output = args.output.absolute()
|
||||
if output.exists() or output.is_symlink():
|
||||
fail(f'Bundle already exists: {output}')
|
||||
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(prefix='.bundle-', dir=output.parent) as temporary:
|
||||
staging = Path(temporary) / 'release'
|
||||
staging.mkdir()
|
||||
files = []
|
||||
for rule in INVENTORY['files']:
|
||||
sources = sorted(source.glob(rule['source']))
|
||||
if not sources:
|
||||
fail(f'Missing bundle input: {rule["source"]}')
|
||||
|
||||
for origin in sources:
|
||||
if origin.is_symlink() or not origin.is_file():
|
||||
fail(f'Bundle input must be a regular file: {origin}')
|
||||
if rule['mode'] == '0755' and not os.access(origin, os.X_OK):
|
||||
fail(f'Bundle input is not executable: {origin}')
|
||||
|
||||
item = record(rule, origin.name)
|
||||
destination = staging / item['target']
|
||||
atomic_copy(destination, origin, int(item['mode'], 8))
|
||||
item['sha256'] = digest(destination)
|
||||
files.append(item)
|
||||
|
||||
def git(*command):
|
||||
result = subprocess.run(['git', '-C', str(source), *command], capture_output=True, text=True)
|
||||
return result.stdout.strip() if result.returncode == 0 else None
|
||||
|
||||
status = git('status', '--porcelain')
|
||||
manifest = dict(format=1, release=args.release_id, commit=git('rev-parse', 'HEAD'),
|
||||
dirty=None if status is None else bool(status), features=args.features,
|
||||
created_utc=time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()), files=files)
|
||||
atomic_json(staging / 'manifest.json', manifest, 0o644)
|
||||
validate_bundle(staging)
|
||||
sync_tree(staging)
|
||||
os.rename(staging, output)
|
||||
sync_dir(output.parent)
|
||||
|
||||
print(f'Prepared bundle: {output}')
|
||||
|
||||
|
||||
class Installation:
|
||||
def __init__(self, root):
|
||||
self.root = root.resolve()
|
||||
self.base = self.path(PREFIX)
|
||||
self.transaction = self.base / '.transaction'
|
||||
|
||||
def path(self, absolute):
|
||||
if not absolute.startswith('/') or '..' in Path(absolute).parts:
|
||||
fail(f'Invalid installation path: {absolute}')
|
||||
|
||||
path = self.root / absolute.lstrip('/')
|
||||
for parent in path.parents:
|
||||
if parent == self.root:
|
||||
break
|
||||
if parent.is_symlink():
|
||||
fail(f'Refusing symlinked parent: {parent}')
|
||||
|
||||
return path
|
||||
|
||||
@contextlib.contextmanager
|
||||
def lock(self):
|
||||
self.base.mkdir(parents=True, exist_ok=True)
|
||||
lock_path = self.path(PREFIX + '/.install.lock')
|
||||
fd = os.open(lock_path, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
|
||||
try:
|
||||
try:
|
||||
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
fail('Another Ferese installation is in progress')
|
||||
|
||||
yield
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
def pointer(self, name):
|
||||
path = self.path(PREFIX + '/' + name)
|
||||
if not os.path.lexists(path):
|
||||
return None
|
||||
if not path.is_symlink():
|
||||
fail(f'Unmanaged release pointer: {path}')
|
||||
|
||||
target = os.readlink(path)
|
||||
if not target.startswith('releases/') or not ID.fullmatch(target[len('releases/'):]):
|
||||
fail(f'Invalid release pointer: {path}')
|
||||
|
||||
return target[len('releases/'):]
|
||||
|
||||
def state(self, absolute):
|
||||
path = self.path(absolute)
|
||||
if path.is_symlink():
|
||||
return {'kind': 'link', 'target': os.readlink(path)}
|
||||
if not path.exists():
|
||||
return {'kind': 'absent'}
|
||||
if not path.is_file():
|
||||
fail(f'Unmanaged non-file path: {path}')
|
||||
|
||||
return {'kind': 'file', 'sha256': digest(path), 'mode': stat.S_IMODE(path.stat().st_mode)}
|
||||
|
||||
def manifest(self, release):
|
||||
directory = self.path(PREFIX + '/releases/' + release_id(release))
|
||||
if not (directory / 'manifest.json').is_file():
|
||||
fail(f'Release {release} predates verified bundles; select a bundled release for rollback')
|
||||
|
||||
return validate_bundle(directory)
|
||||
|
||||
def current_publication(self, current):
|
||||
if not current:
|
||||
return {}
|
||||
|
||||
directory = self.path(PREFIX + '/releases/' + current)
|
||||
if (directory / 'manifest.json').exists():
|
||||
return self.publication(self.manifest(current))
|
||||
|
||||
# The previous installer retained exact originals for these integration files.
|
||||
# Use those ownership receipts for the first upgrade, never the live file itself.
|
||||
if directory.is_symlink() or not (directory / 'installer-files').is_dir():
|
||||
fail(f'Release has no installation manifest: {current}')
|
||||
|
||||
entries = {}
|
||||
for rule in INVENTORY['files']:
|
||||
if rule.get('link'):
|
||||
target = rule['target']
|
||||
entries['/usr/local/bin/' + target] = (
|
||||
{'kind': 'link', 'target': PREFIX + '/current/' + target}, None, 'managed')
|
||||
if 'destination' in rule:
|
||||
saved = directory / 'installer-files' / rule['source'].removeprefix('packaging/')
|
||||
if saved.is_file() and not saved.is_symlink():
|
||||
entries[rule['destination']] = (
|
||||
{'kind': 'file', 'sha256': digest(saved), 'mode': int(rule['mode'], 8)},
|
||||
None, rule['policy'])
|
||||
|
||||
return entries
|
||||
|
||||
def publication(self, manifest):
|
||||
entries = {}
|
||||
for item in manifest['files']:
|
||||
if item.get('link'):
|
||||
destination = '/usr/local/bin/' + item['target']
|
||||
entries[destination] = ({'kind': 'link', 'target': PREFIX + '/current/' + item['target']}, None, 'managed')
|
||||
if 'destination' in item:
|
||||
source = PREFIX + '/releases/' + manifest['release'] + '/' + item['target']
|
||||
entries[item['destination']] = ({'kind': 'file', 'sha256': item['sha256'],
|
||||
'mode': int(item['mode'], 8)}, source, item['policy'])
|
||||
|
||||
return entries
|
||||
|
||||
def plan(self, manifest, replace_portal=False, remove=False):
|
||||
current = self.pointer('current')
|
||||
self.pointer('previous')
|
||||
previous = self.current_publication(current)
|
||||
desired = {} if remove else self.publication(manifest)
|
||||
operations = []
|
||||
for destination in sorted(previous.keys() | desired.keys()):
|
||||
old = self.state(destination)
|
||||
new, source, policy = desired.get(destination, ({'kind': 'absent'}, None, 'managed'))
|
||||
owned = previous.get(destination, (None,))[0]
|
||||
if old != {'kind': 'absent'} and old != owned and old != new:
|
||||
if not (replace_portal and policy == 'portal' and old['kind'] == 'file'):
|
||||
fail(f'Unmanaged or modified file: {destination}')
|
||||
|
||||
if old != new:
|
||||
operations.append(dict(path=destination, old=old, new=new, source=source))
|
||||
|
||||
pam_path = '/etc/pam.d/ferese-lock'
|
||||
if not remove and not os.path.lexists(self.path(pam_path)):
|
||||
if self.path('/etc/pam.d/system-auth').is_file():
|
||||
policy = 'fedora'
|
||||
elif all(self.path('/etc/pam.d/' + name).is_file() for name in ('common-auth', 'common-account')):
|
||||
policy = 'debian'
|
||||
elif self.path('/etc/pam.d/login').is_file():
|
||||
policy = 'generic'
|
||||
else:
|
||||
fail('No supported PAM stack; install /etc/pam.d/ferese-lock first')
|
||||
|
||||
item = next(item for item in manifest['files'] if item['target'] == 'pam/' + policy)
|
||||
operations.append(dict(path=pam_path, old={'kind': 'absent'},
|
||||
new={'kind': 'file', 'sha256': item['sha256'], 'mode': 0o644},
|
||||
source=PREFIX + '/releases/' + manifest['release'] + '/' + item['target']))
|
||||
|
||||
for name, target in [('previous', self.pointer('previous') if current == manifest['release'] and not remove else current),
|
||||
('current', None if remove else manifest['release'])]:
|
||||
old = self.state(PREFIX + '/' + name)
|
||||
new = {'kind': 'link', 'target': 'releases/' + target} if target else {'kind': 'absent'}
|
||||
if old != new:
|
||||
operations.append(dict(path=PREFIX + '/' + name, old=old, new=new, source=None))
|
||||
|
||||
return operations
|
||||
|
||||
def write_state(self, absolute, state, source=None):
|
||||
path = self.path(absolute)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if state['kind'] == 'absent':
|
||||
path.unlink(missing_ok=True)
|
||||
sync_dir(path.parent)
|
||||
elif state['kind'] == 'file':
|
||||
if digest(source) != state['sha256']:
|
||||
fail(f'Recovery source checksum mismatch: {source}')
|
||||
|
||||
atomic_copy(path, source, state['mode'])
|
||||
else:
|
||||
temporary = path.parent / ('.ferese-link-' + uuid.uuid4().hex)
|
||||
try:
|
||||
temporary.symlink_to(state['target'])
|
||||
os.replace(temporary, path)
|
||||
sync_dir(path.parent)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
def archive(self, journal):
|
||||
history = self.path(PREFIX + '/transactions')
|
||||
history.mkdir(mode=0o700, exist_ok=True)
|
||||
os.rename(self.transaction, history / journal['id'])
|
||||
sync_dir(history)
|
||||
sync_dir(self.base)
|
||||
|
||||
def recover(self):
|
||||
if not self.transaction.exists():
|
||||
return
|
||||
|
||||
if self.transaction.is_symlink():
|
||||
fail('Refusing symlinked recovery transaction')
|
||||
|
||||
journal = json.loads((self.transaction / 'journal.json').read_text())
|
||||
if journal['phase'] != 'committed':
|
||||
# Check all paths before restoring anything; preserve later administrator edits.
|
||||
for operation in journal['operations']:
|
||||
actual = self.state(operation['path'])
|
||||
if actual not in (operation['old'], operation['new']):
|
||||
fail(f'Recovery conflict at {operation["path"]}; transaction retained at {self.transaction}')
|
||||
|
||||
for index, operation in reversed(list(enumerate(journal['operations']))):
|
||||
self.write_state(operation['path'], operation['old'], self.transaction / 'backup' / str(index))
|
||||
|
||||
journal['phase'] = 'rolled-back'
|
||||
atomic_json(self.transaction / 'journal.json', journal)
|
||||
print('Recovered interrupted installation; restored previous files')
|
||||
|
||||
self.archive(journal)
|
||||
|
||||
def apply(self, operations):
|
||||
if not operations:
|
||||
print('Already installed; no system changes needed')
|
||||
return
|
||||
|
||||
journal = dict(id=time.strftime('%Y%m%dT%H%M%S', time.gmtime()) + '-' + uuid.uuid4().hex,
|
||||
phase='applying', operations=operations)
|
||||
with tempfile.TemporaryDirectory(prefix='.prepare-', dir=self.base) as temporary:
|
||||
staging = Path(temporary) / 'transaction'
|
||||
staging.mkdir(mode=0o700)
|
||||
for index, operation in enumerate(operations):
|
||||
if self.state(operation['path']) != operation['old']:
|
||||
fail(f'File changed during preparation: {operation["path"]}')
|
||||
if operation['old']['kind'] == 'file':
|
||||
backup = staging / 'backup' / str(index)
|
||||
atomic_copy(backup, self.path(operation['path']), 0o600)
|
||||
if digest(backup) != operation['old']['sha256']:
|
||||
fail(f'File changed while backing up: {operation["path"]}')
|
||||
|
||||
atomic_json(staging / 'journal.json', journal)
|
||||
sync_tree(staging)
|
||||
os.rename(staging, self.transaction)
|
||||
sync_dir(self.base)
|
||||
|
||||
try:
|
||||
for operation in operations:
|
||||
if self.state(operation['path']) != operation['old']:
|
||||
fail(f'File changed during installation: {operation["path"]}')
|
||||
|
||||
source = self.path(operation['source']) if operation['source'] else None
|
||||
self.write_state(operation['path'], operation['new'], source)
|
||||
|
||||
journal['phase'] = 'committed'
|
||||
atomic_json(self.transaction / 'journal.json', journal)
|
||||
except BaseException:
|
||||
self.recover()
|
||||
raise
|
||||
|
||||
self.archive(journal)
|
||||
|
||||
def stage(self, origin, manifest):
|
||||
releases = self.path(PREFIX + '/releases')
|
||||
if releases.is_symlink():
|
||||
fail(f'Refusing symlinked release directory: {releases}')
|
||||
|
||||
releases.mkdir(parents=True, exist_ok=True)
|
||||
destination = releases / manifest['release']
|
||||
if os.path.lexists(destination):
|
||||
if self.manifest(manifest['release']) != manifest:
|
||||
fail(f'Release ID already belongs to a different bundle: {manifest["release"]}')
|
||||
|
||||
return
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix='.stage-', dir=releases) as temporary:
|
||||
staging = Path(temporary) / 'release'
|
||||
staging.mkdir()
|
||||
for item in manifest['files']:
|
||||
atomic_copy(staging / item['target'], origin / item['target'], int(item['mode'], 8))
|
||||
|
||||
atomic_json(staging / 'manifest.json', manifest, 0o644)
|
||||
validate_bundle(staging)
|
||||
sync_tree(staging)
|
||||
os.rename(staging, destination)
|
||||
sync_dir(releases)
|
||||
|
||||
|
||||
def system_command(args):
|
||||
if args.root.resolve() == Path('/') and os.geteuid() != 0 and not args.dry_run:
|
||||
fail('System installation requires sudo or pkexec')
|
||||
|
||||
installation = Installation(args.root)
|
||||
if args.dry_run:
|
||||
if installation.transaction.exists():
|
||||
fail('An interrupted transaction requires recovery before planning')
|
||||
|
||||
if args.command == 'recover':
|
||||
print('No interrupted transaction')
|
||||
return
|
||||
if args.command == 'install':
|
||||
manifest = validate_bundle(args.bundle)
|
||||
else:
|
||||
selected = (installation.pointer('current') if args.command == 'uninstall' else
|
||||
args.release or installation.pointer('previous'))
|
||||
manifest = installation.manifest(selected or fail('No release selected'))
|
||||
operations = installation.plan(manifest, getattr(args, 'replace_portal_config', False), args.command == 'uninstall')
|
||||
for operation in operations:
|
||||
print(f'{operation["path"]}: {operation["old"]["kind"]} -> {operation["new"]["kind"]}')
|
||||
return
|
||||
|
||||
with installation.lock():
|
||||
installation.recover()
|
||||
if args.command == 'recover':
|
||||
return
|
||||
if args.command == 'install':
|
||||
manifest = validate_bundle(args.bundle)
|
||||
# Preflight before retaining a release or changing integration files.
|
||||
operations = installation.plan(manifest, args.replace_portal_config)
|
||||
installation.stage(args.bundle, manifest)
|
||||
elif args.command == 'rollback':
|
||||
selected = args.release or installation.pointer('previous') or fail('No previous release')
|
||||
manifest = installation.manifest(selected)
|
||||
operations = installation.plan(manifest)
|
||||
else:
|
||||
current = installation.pointer('current') or fail('No installed release')
|
||||
manifest = installation.manifest(current)
|
||||
operations = installation.plan(manifest, remove=True)
|
||||
|
||||
installation.apply(operations)
|
||||
print(f'System {args.command} complete: {manifest["release"]}')
|
||||
|
||||
|
||||
def user_setup(args):
|
||||
if os.geteuid() == 0:
|
||||
fail('Run user-setup as your normal user')
|
||||
|
||||
config = Path(os.environ.get('XDG_CONFIG_HOME', str(Path.home() / '.config')))
|
||||
override = config / 'systemd/user/xdg-desktop-portal-ferese.service.d/override.conf'
|
||||
expected = f'[Service]\nExecStart=\nExecStart={Path.home()}/.local/libexec/ferese/xdg-desktop-portal-ferese\n'
|
||||
if os.path.lexists(override):
|
||||
if override.is_symlink() or override.read_text() != expected:
|
||||
fail(f'Custom portal override preserved: {override}; update its ExecStart to {PREFIX}/current/xdg-desktop-portal-ferese')
|
||||
if not args.check:
|
||||
backup = override.with_name('override.conf.before-' + uuid.uuid4().hex)
|
||||
override.rename(backup)
|
||||
print(f'Backed up development portal override: {backup}')
|
||||
|
||||
if not args.check:
|
||||
subprocess.run(['systemctl', '--user', 'daemon-reload'], check=True)
|
||||
print('User setup complete. Log out and select Ferese to use the installed release.')
|
||||
|
||||
|
||||
def parser():
|
||||
result = argparse.ArgumentParser(description=__doc__)
|
||||
commands = result.add_subparsers(dest='command', required=True)
|
||||
build = commands.add_parser('bundle')
|
||||
build.add_argument('--source-root', type=Path, default=REPO)
|
||||
build.add_argument('--output', type=Path, required=True)
|
||||
build.add_argument('--release-id', required=True)
|
||||
build.add_argument('--features', default='default')
|
||||
commands.add_parser('packages')
|
||||
verify = commands.add_parser('verify')
|
||||
verify.add_argument('bundle', type=Path)
|
||||
user = commands.add_parser('user-setup')
|
||||
user.add_argument('--check', action='store_true')
|
||||
for name in ('install', 'rollback', 'recover', 'uninstall'):
|
||||
command = commands.add_parser(name)
|
||||
command.add_argument('--root', type=Path, default=Path('/'), help='Destination filesystem root (for packaging/tests)')
|
||||
command.add_argument('--dry-run', action='store_true')
|
||||
if name == 'install':
|
||||
command.add_argument('bundle', type=Path)
|
||||
command.add_argument('--replace-portal-config', action='store_true')
|
||||
elif name == 'rollback':
|
||||
command.add_argument('release', nargs='?')
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
args = parser().parse_args()
|
||||
if args.command == 'bundle':
|
||||
bundle(args)
|
||||
elif args.command == 'verify':
|
||||
manifest = validate_bundle(args.bundle)
|
||||
print(f'Verified bundle: {manifest["release"]}')
|
||||
elif args.command == 'packages':
|
||||
print('\n'.join(INVENTORY['packages']))
|
||||
elif args.command == 'user-setup':
|
||||
user_setup(args)
|
||||
else:
|
||||
system_command(args)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main()
|
||||
except (ValueError, OSError, KeyError, TypeError, subprocess.CalledProcessError) as error:
|
||||
print(f'ferese installer: {error}', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,187 @@
|
||||
{
|
||||
"packages": [
|
||||
"ferese",
|
||||
"ferese-shell",
|
||||
"ferese-settings",
|
||||
"feresectl",
|
||||
"ferese-lock",
|
||||
"ferese-polkit-agent",
|
||||
"xdg-desktop-portal-ferese"
|
||||
],
|
||||
"files": [
|
||||
{
|
||||
"source": "target/release/ferese",
|
||||
"target": "ferese",
|
||||
"mode": "0755",
|
||||
"link": true
|
||||
},
|
||||
{
|
||||
"source": "target/release/ferese-shell",
|
||||
"target": "ferese-shell",
|
||||
"mode": "0755",
|
||||
"link": true
|
||||
},
|
||||
{
|
||||
"source": "target/release/ferese-settings",
|
||||
"target": "ferese-settings",
|
||||
"mode": "0755",
|
||||
"link": true
|
||||
},
|
||||
{
|
||||
"source": "target/release/feresectl",
|
||||
"target": "feresectl",
|
||||
"mode": "0755",
|
||||
"link": true
|
||||
},
|
||||
{
|
||||
"source": "target/release/ferese-lock",
|
||||
"target": "ferese-lock",
|
||||
"mode": "0755",
|
||||
"link": true
|
||||
},
|
||||
{
|
||||
"source": "target/release/ferese-polkit-agent",
|
||||
"target": "ferese-polkit-agent",
|
||||
"mode": "0755",
|
||||
"link": true
|
||||
},
|
||||
{
|
||||
"source": "target/release/xdg-desktop-portal-ferese",
|
||||
"target": "xdg-desktop-portal-ferese",
|
||||
"mode": "0755",
|
||||
"link": true
|
||||
},
|
||||
{
|
||||
"source": "target/release/ferese-record",
|
||||
"target": "ferese-record",
|
||||
"mode": "0755",
|
||||
"link": true
|
||||
},
|
||||
{
|
||||
"source": "packaging/ferese-session",
|
||||
"target": "ferese-session",
|
||||
"mode": "0755",
|
||||
"link": true
|
||||
},
|
||||
{
|
||||
"source": "packaging/ferese-session-shell",
|
||||
"target": "ferese-session-shell",
|
||||
"mode": "0755",
|
||||
"link": false
|
||||
},
|
||||
{
|
||||
"source": "packaging/ferese-screenshot",
|
||||
"target": "ferese-screenshot",
|
||||
"mode": "0755",
|
||||
"link": true
|
||||
},
|
||||
{
|
||||
"source": "packaging/config.kdl",
|
||||
"target": "config.example.kdl",
|
||||
"mode": "0644"
|
||||
},
|
||||
{
|
||||
"source": "assets/fonts/Comfortaa-LICENSE.txt",
|
||||
"target": "licenses/Comfortaa-LICENSE.txt",
|
||||
"mode": "0644"
|
||||
},
|
||||
{
|
||||
"source": "assets/fonts/Cantarell-LICENSE.txt",
|
||||
"target": "licenses/Cantarell-LICENSE.txt",
|
||||
"mode": "0644"
|
||||
},
|
||||
{
|
||||
"source": "assets/themes/licenses/*.txt",
|
||||
"target": "licenses/theme-{name}",
|
||||
"mode": "0644"
|
||||
},
|
||||
{
|
||||
"source": "assets/wallpapers/ferese.png",
|
||||
"target": "wallpapers/ferese.png",
|
||||
"mode": "0644"
|
||||
},
|
||||
{
|
||||
"source": "assets/wallpapers/ferese.svg",
|
||||
"target": "wallpapers/ferese.svg",
|
||||
"mode": "0644"
|
||||
},
|
||||
{
|
||||
"source": "packaging/ferese.desktop",
|
||||
"target": "integration/usr/share/wayland-sessions/ferese.desktop",
|
||||
"mode": "0644",
|
||||
"destination": "/usr/share/wayland-sessions/ferese.desktop",
|
||||
"policy": "managed"
|
||||
},
|
||||
{
|
||||
"source": "packaging/dev.ferese.Settings.desktop",
|
||||
"target": "integration/usr/local/share/applications/dev.ferese.Settings.desktop",
|
||||
"mode": "0644",
|
||||
"destination": "/usr/local/share/applications/dev.ferese.Settings.desktop",
|
||||
"policy": "managed"
|
||||
},
|
||||
{
|
||||
"source": "packaging/icons/ferese.svg",
|
||||
"target": "integration/usr/local/share/icons/hicolor/scalable/apps/ferese.svg",
|
||||
"mode": "0644",
|
||||
"destination": "/usr/local/share/icons/hicolor/scalable/apps/ferese.svg",
|
||||
"policy": "managed"
|
||||
},
|
||||
{
|
||||
"source": "packaging/icons/ferese.svg",
|
||||
"target": "integration/usr/local/share/icons/hicolor/scalable/apps/dev.ferese.Settings.svg",
|
||||
"mode": "0644",
|
||||
"destination": "/usr/local/share/icons/hicolor/scalable/apps/dev.ferese.Settings.svg",
|
||||
"policy": "managed"
|
||||
},
|
||||
{
|
||||
"source": "packaging/systemd/ferese-session.target",
|
||||
"target": "integration/usr/local/lib/systemd/user/ferese-session.target",
|
||||
"mode": "0644",
|
||||
"destination": "/usr/local/lib/systemd/user/ferese-session.target",
|
||||
"policy": "managed"
|
||||
},
|
||||
{
|
||||
"source": "packaging/systemd/xdg-desktop-portal-ferese.service",
|
||||
"target": "integration/usr/local/lib/systemd/user/xdg-desktop-portal-ferese.service",
|
||||
"mode": "0644",
|
||||
"destination": "/usr/local/lib/systemd/user/xdg-desktop-portal-ferese.service",
|
||||
"policy": "managed"
|
||||
},
|
||||
{
|
||||
"source": "packaging/portal/ferese.portal",
|
||||
"target": "integration/usr/share/xdg-desktop-portal/portals/ferese.portal",
|
||||
"mode": "0644",
|
||||
"destination": "/usr/share/xdg-desktop-portal/portals/ferese.portal",
|
||||
"policy": "portal"
|
||||
},
|
||||
{
|
||||
"source": "packaging/portal/ferese-portals.conf",
|
||||
"target": "integration/usr/share/xdg-desktop-portal/ferese-portals.conf",
|
||||
"mode": "0644",
|
||||
"destination": "/usr/share/xdg-desktop-portal/ferese-portals.conf",
|
||||
"policy": "portal"
|
||||
},
|
||||
{
|
||||
"source": "packaging/portal/org.freedesktop.impl.portal.desktop.ferese.service",
|
||||
"target": "integration/usr/local/share/dbus-1/services/org.freedesktop.impl.portal.desktop.ferese.service",
|
||||
"mode": "0644",
|
||||
"destination": "/usr/local/share/dbus-1/services/org.freedesktop.impl.portal.desktop.ferese.service",
|
||||
"policy": "managed"
|
||||
},
|
||||
{
|
||||
"source": "packaging/pam.d/ferese-lock.fedora",
|
||||
"target": "pam/fedora",
|
||||
"mode": "0644"
|
||||
},
|
||||
{
|
||||
"source": "packaging/pam.d/ferese-lock.debian",
|
||||
"target": "pam/debian",
|
||||
"mode": "0644"
|
||||
},
|
||||
{
|
||||
"source": "packaging/pam.d/ferese-lock.generic",
|
||||
"target": "pam/generic",
|
||||
"mode": "0644"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user