diff options
| author | Crucible <crucible@localhost> | 2026-09-23 01:27:26 -0500 |
|---|---|---|
| committer | Crucible <crucible@localhost> | 2026-09-23 01:27:26 -0500 |
| commit | 89638002224beb1b5cc8a81e8f7abb9c0943aaed (patch) | |
| tree | 1708e516820c278bc2f192056872632412216a8d /app-admin | |
| parent | d7f82bffe8354989cbca631fdb55135397e3b0b1 (diff) | |
| download | srcux-develop.tar.gz srcux-develop.tar.xz srcux-develop.zip | |
Diffstat (limited to 'app-admin')
| -rw-r--r-- | app-admin/awscli/Manifest | 1 | ||||
| -rw-r--r-- | app-admin/awscli/awscli-2.37.0.ebuild | 113 | ||||
| -rw-r--r-- | app-admin/osforge/files/osforge-0.4.0-crucible-update.patch | 284 | ||||
| -rw-r--r-- | app-admin/osforge/osforge-0.4.0-r2.ebuild | 58 |
4 files changed, 456 insertions, 0 deletions
diff --git a/app-admin/awscli/Manifest b/app-admin/awscli/Manifest index 4edef00eebb6..22032faee0f2 100644 --- a/app-admin/awscli/Manifest +++ b/app-admin/awscli/Manifest @@ -6,3 +6,4 @@ DIST aws-cli-2.36.47.gh.tar.gz 19288749 BLAKE2B 998322f865614ff38445836c6385bbdd DIST aws-cli-2.36.48.gh.tar.gz 19323282 BLAKE2B ed808c8a253bbe65599c1dbd4415aaa930a45a2b5c8233f86a5ca5529ca7cedb0a6534125ad0258204ad61a9eb66851e865b0241f8cbfe2da208695eebf503c6 SHA512 90269562aec7886a6d99682091331be058f26a67e16a32e375d46fbf4a32c5151f874bdb85297c00557d2ef581ca2d4b77b4d7f20fde12ccd6d43c7a27f8e1f0 DIST aws-cli-2.36.49.gh.tar.gz 19327940 BLAKE2B 408eb80778de27662f015988fc1729ee6c54f5ce0de656351a8a85612b810e9d0c3596ee7d315a3af3d9cbfcd247a75772d2de68240ecd8d292ac0d0729f1965 SHA512 88d8171dda087cceaf44d00f944a9b69da51bcefed28689271807fcdbda2ea2565e472f7a20f05836ec0c27fd17c630078675d1284ea438058748d901e87dca3 DIST aws-cli-2.36.50.gh.tar.gz 19338543 BLAKE2B 18f08faf504224eaa9715f683e7e122b8e28a099df10b117f88811d5c71e76941df52e5dcd66be38461498503fb6dfce4deb0e73f9f910865b0821607971f9a9 SHA512 a7d26a8a7b030f2cf4e577a6ceed990cb659f2e7e406aed4f67c00be4612c74dfa4d26a3c98adafe02d97103a86a30074d492008c3e8c4a38a3820d52463a4db +DIST aws-cli-2.37.0.gh.tar.gz 19386999 BLAKE2B 3205f30799fcbaa2454c3334ce0b7fca5033f8328e7b4af20c40f4b07fe7a44c82030bce4e5f176ed802677e63b296f1033955d821accdde7461fe7f03709b5e SHA512 4413c96945c9fdc0ea5e1099723fba5b60c707a6eb3d45f829fb88084f8f9bdbc1bbc8df95666e906dd35f5a000e99ab0a32f4c1bc8a57df42780007a7fd9657 diff --git a/app-admin/awscli/awscli-2.37.0.ebuild b/app-admin/awscli/awscli-2.37.0.ebuild new file mode 100644 index 000000000000..06abe1e785c5 --- /dev/null +++ b/app-admin/awscli/awscli-2.37.0.ebuild @@ -0,0 +1,113 @@ +# Copyright 1999-2026 Gentoo Authors +# Distributed under the terms of the GNU General Public License v2 + +EAPI=8 + +DISTUTILS_USE_PEP517=standalone +PYTHON_COMPAT=( python3_{13..14} ) + +inherit distutils-r1 shell-completion + +MY_P=aws-cli-${PV} +DESCRIPTION="Universal Command Line Environment for AWS" +HOMEPAGE=" + https://github.com/aws/aws-cli/ + https://pypi.org/project/awscli/ +" +SRC_URI=" + https://github.com/aws/aws-cli/archive/${PV}.tar.gz + -> ${MY_P}.gh.tar.gz +" +S=${WORKDIR}/${MY_P} + +LICENSE="Apache-2.0" +SLOT="0" +KEYWORDS="~amd64 ~arm64 ~riscv" + +RDEPEND=" + >=dev-python/colorama-0.2.5[${PYTHON_USEDEP}] + >=dev-python/docutils-0.10[${PYTHON_USEDEP}] + >=dev-python/ruamel-yaml-0.15.0[${PYTHON_USEDEP}] + >=dev-python/prompt-toolkit-3.0.24[${PYTHON_USEDEP}] + >=dev-python/distro-1.5.0[${PYTHON_USEDEP}] + >=dev-python/awscrt-0.36.2[${PYTHON_USEDEP}] + >=dev-python/python-dateutil-2.1[${PYTHON_USEDEP}] + >=dev-python/jmespath-0.7.1[${PYTHON_USEDEP}] + >=dev-python/urllib3-1.25.4[${PYTHON_USEDEP}] +" +# bundled dependencies +RDEPEND+=" + >=dev-python/s3transfer-0.18.0[${PYTHON_USEDEP}] +" +TODO=" + dev-python/rsa[${PYTHON_USEDEP}] + dev-python/pyyaml[${PYTHON_USEDEP}] + !app-admin/awscli-bin +" +BDEPEND=" + dev-python/flit-core[${PYTHON_USEDEP}] + test? ( + dev-python/jsonschema[${PYTHON_USEDEP}] + dev-python/packaging[${PYTHON_USEDEP}] + ) +" + +EPYTEST_PLUGINS=() +EPYTEST_RERUNS=5 +EPYTEST_XDIST=1 +distutils_enable_tests pytest + +src_prepare() { + distutils-r1_src_prepare + + # this package bundles botocore & s3transfer, but they are custom + # v2 branches that were never released and are incompatible with + # our release versions + + # strip overzealous upper bounds on requirements + sed -i -e 's:,<[=0-9.]*::' -e 's:==:>=:' pyproject.toml || die + # wcwidth isn't used + sed -i -e '/wcwidth/d' pyproject.toml || die +} + +python_test() { + local -x TZ=UTC + + local EPYTEST_DESELECT=( + # TODO + tests/functional/autocomplete/test_main.py::test_smoke_test_completer + tests/unit/customizations/ec2instanceconnect/test_websocket.py::TestWebsocket::test_connect_with_proxy + tests/unit/customizations/ec2instanceconnect/test_websocket.py::TestWebsocket::test_connect_with_proxy_but_no_proxy_env_empty + tests/unit/customizations/emr/test_emr_utils.py::TestEMRutils::test_which_with_existing_command + tests/unit/customizations/emr/test_emr_utils.py::TestEMRutils::test_which_with_non_existing_command + tests/unit/customizations/wizard/test_app.py + tests/unit/test_compat.py::TestGetPreferredEncoding::test_getpreferredencoding_with_env_var + ) + + # Some tests take a lot of memory, so run parallel-safe tests first. + local awscli_parallel_tests=( + tests/functional/[ac-z]*/ + tests/unit/ + ) + local botocore_parallel_tests=( + tests/functional/botocore/*/ + tests/functional/botocore/test_[cds-z]*.py + ) + #epytest "${botocore_parallel_tests[@]}" + epytest "${awscli_parallel_tests[@]}" + + local EPYTEST_IGNORE=( + "${botocore_parallel_tests[@]}" + "${awscli_parallel_tests[@]}" + ) + EPYTEST_XDIST= epytest tests/functional +} + +python_install_all() { + newbashcomp bin/aws_bash_completer aws + newzshcomp bin/aws_zsh_completer.sh _aws + + distutils-r1_python_install_all + + rm "${ED}"/usr/bin/{aws.cmd,aws_bash_completer,aws_zsh_completer.sh} || die +} diff --git a/app-admin/osforge/files/osforge-0.4.0-crucible-update.patch b/app-admin/osforge/files/osforge-0.4.0-crucible-update.patch new file mode 100644 index 000000000000..1cad7175bc58 --- /dev/null +++ b/app-admin/osforge/files/osforge-0.4.0-crucible-update.patch @@ -0,0 +1,284 @@ +diff --git a/src/osforge/merge.py b/src/osforge/merge.py +index 308e1e9..4fd6fae 100644 +--- a/src/osforge/merge.py ++++ b/src/osforge/merge.py +@@ -6 +6 @@ import fnmatch +-from functools import cmp_to_key ++from functools import cmp_to_key, lru_cache +@@ -887,0 +888,8 @@ def remove_path(path: Path) -> None: ++@lru_cache(maxsize=128) ++def _compiled_filters(filters: tuple[str, ...]): ++ patterns = frozenset(pattern.strip("/") for pattern in filters if pattern.strip("/")) ++ globs = [fnmatch.translate(pattern) for pattern in sorted(patterns) ++ if any(char in pattern for char in "*?[")] ++ return patterns, re.compile("|".join(globs)) if globs else None ++ ++ +@@ -890 +898 @@ def rel_filtered(relpath: str, filters: list[str]) -> bool: +- if relpath in ("", "."): ++ if relpath in ("", ".") or not filters: +@@ -892,7 +900,6 @@ def rel_filtered(relpath: str, filters: list[str]) -> bool: +- for raw_pattern in filters: +- pattern = raw_pattern.strip("/") +- if not pattern: +- continue +- if relpath == pattern or relpath.startswith(f"{pattern}/"): +- return True +- if fnmatch.fnmatchcase(relpath, pattern): ++ patterns, globs = _compiled_filters(tuple(filters)) ++ # Exact directory rules also exclude descendants. This includes literal ++ # names containing glob characters, as the original matcher did. ++ ancestor = relpath ++ while ancestor: ++ if ancestor in patterns: +@@ -900 +907,2 @@ def rel_filtered(relpath: str, filters: list[str]) -> bool: +- return False ++ ancestor = ancestor.rpartition("/")[0] ++ return globs is not None and globs.match(relpath) is not None +diff --git a/src/osforge/repositories.py b/src/osforge/repositories.py +index f80c013..b18ca77 100644 +--- a/src/osforge/repositories.py ++++ b/src/osforge/repositories.py +@@ -21 +21 @@ from .jobs import load_run_details +-from .transition import Runtime, Operation, atomic_json, atomic_text, lock, name, read_toml, sync_directory, tree_digest ++from .transition import Runtime, Operation, atomic_json, atomic_text, lock, name, read_toml, sync_directory, tree_digest, validate_tree +@@ -135 +135 @@ def overlay(source: Path, destination: Path, *, selection: dict | None = None) - +- tree_digest(source) # reject external links and special files before copying ++ validate_tree(source) # content hashes are checked at capture/freeze/publication boundaries +diff --git a/src/osforge/repository_activation.py b/src/osforge/repository_activation.py +index e6d0682..ea84673 100644 +--- a/src/osforge/repository_activation.py ++++ b/src/osforge/repository_activation.py +@@ -85,0 +86,5 @@ def prepare_release_access(data: dict, home: Path, release: Path) -> None: ++ if release.is_symlink(): ++ raise ValueError("release root must be a real directory") ++ # The tree's containing directory is outside tree_digest, just like its ++ # release parent. A private publisher umask must not hide valid contents. ++ os.chmod(release, stat.S_IMODE(release.stat().st_mode) | stat.S_IRGRP | stat.S_IXGRP) +diff --git a/src/osforge/transition.py b/src/osforge/transition.py +index db47908..7e79634 100644 +--- a/src/osforge/transition.py ++++ b/src/osforge/transition.py +@@ -97,2 +97,2 @@ def lock(path: Path) -> Iterator[None]: +-def tree_digest(root: Path) -> str: +- """Hash names, modes, link targets and bytes, excluding Git administration.""" ++def _tree_entries(root: Path): ++ """Visit validated entries in the existing manifest's deterministic order.""" +@@ -101 +101 @@ def tree_digest(root: Path) -> str: +- digest = hashlib.sha256() ++ resolved = root.resolve() +@@ -104 +104,2 @@ def tree_digest(root: Path) -> str: +- base = Path(directory) ++ relative_dir = os.path.relpath(directory, root) ++ prefix = "" if relative_dir == "." else relative_dir + "/" +@@ -108,5 +109,5 @@ def tree_digest(root: Path) -> str: +- path = base / item +- info = path.lstat() +- relative = path.relative_to(root).as_posix() +- digest.update(json.dumps([relative, stat.S_IMODE(info.st_mode)], separators=(",", ":")).encode()) +- if path.is_symlink(): ++ path = os.path.join(directory, item) ++ info = os.lstat(path) ++ relative = prefix + item ++ target = None ++ if stat.S_ISLNK(info.st_mode): +@@ -114 +115 @@ def tree_digest(root: Path) -> str: +- if not path.resolve(strict=False).is_relative_to(root.resolve()): ++ if not Path(path).resolve(strict=False).is_relative_to(resolved): +@@ -116,9 +117 @@ def tree_digest(root: Path) -> str: +- digest.update(b"L" + os.fsencode(target)) +- elif path.is_file(): +- digest.update(b"F") +- with path.open("rb") as handle: +- for block in iter(lambda: handle.read(1024 * 1024), b""): +- digest.update(block) +- elif path.is_dir(): +- digest.update(b"D") +- else: ++ elif not (stat.S_ISREG(info.st_mode) or stat.S_ISDIR(info.st_mode)): +@@ -125,0 +119,23 @@ def tree_digest(root: Path) -> str: ++ yield relative, path, info.st_mode, target ++ ++ ++def validate_tree(root: Path) -> None: ++ """Reject escaping links and special files without reading package bytes.""" ++ for _entry in _tree_entries(root): ++ pass ++ ++ ++def tree_digest(root: Path) -> str: ++ """Hash names, modes, link targets and bytes, excluding Git administration.""" ++ digest = hashlib.sha256() ++ for relative, path, mode, target in _tree_entries(root): ++ digest.update(json.dumps([relative, stat.S_IMODE(mode)], separators=(",", ":")).encode()) ++ if target is not None: ++ digest.update(b"L" + os.fsencode(target)) ++ elif stat.S_ISREG(mode): ++ digest.update(b"F") ++ with open(path, "rb") as handle: ++ for block in iter(lambda: handle.read(65536), b""): ++ digest.update(block) ++ else: ++ digest.update(b"D") +diff --git a/tests/test_repository_activation.py b/tests/test_repository_activation.py +index 0979c78..e10064d 100644 +--- a/tests/test_repository_activation.py ++++ b/tests/test_repository_activation.py +@@ -112,0 +113,35 @@ class RepositoryActivationTests(unittest.TestCase): ++ def test_publication_under_private_umask_keeps_release_readable(self): ++ previous = os.umask(0o077) ++ try: ++ candidate = self.update() ++ digest = tree_digest(self.home / 'candidates' / candidate / 'tree') ++ self.publish(candidate) ++ finally: ++ os.umask(previous) ++ self.assertEqual(self.location().stat().st_mode & 0o050, 0o050) ++ self.assertEqual(tree_digest(self.location()), digest) ++ ++ def test_retry_repairs_only_the_retained_tree_container(self): ++ candidate = self.update() ++ with patch('osforge.repositories.prepare_release_access', side_effect=OSError('interrupted before activation')): ++ with self.assertRaisesRegex(OSError, 'interrupted'): ++ self.publish(candidate) ++ release = self.home / 'releases' / candidate / 'tree' ++ release.chmod(0o700) ++ digest = tree_digest(release) ++ self.publish(candidate) ++ self.assertEqual(tree_digest(release), digest) ++ self.assertEqual((release / 'payload').stat().st_mode & 0o777, 0o644) ++ self.assertEqual(git('--git-dir', str(self.home / 'repository.git'), 'rev-list', '--count', 'develop'), '1') ++ ++ def test_release_root_symlink_cannot_change_outside_permissions(self): ++ from osforge.repository_activation import prepare_release_access ++ outside = self.root / 'private' ++ outside.mkdir(mode=0o700) ++ release = self.home / 'releases/linked-tree' ++ release.symlink_to(outside, target_is_directory=True) ++ data = read_toml(self.runtime.repositories_dir / 'example.toml') ++ with self.assertRaisesRegex(ValueError, 'real directory'): ++ prepare_release_access(data, self.home, release) ++ self.assertEqual(outside.stat().st_mode & 0o777, 0o700) ++ +diff --git a/tests/test_repository_tree.py b/tests/test_repository_tree.py +new file mode 100644 +index 0000000..ca70e3b +--- /dev/null ++++ b/tests/test_repository_tree.py +@@ -0,0 +1,116 @@ ++from pathlib import Path ++from tempfile import TemporaryDirectory ++import fnmatch ++import os ++import unittest ++ ++from osforge.merge import rel_filtered ++from osforge.repositories import overlay ++from osforge.transition import tree_digest, validate_tree ++ ++ ++class FilterMatchingTests(unittest.TestCase): ++ def test_exact_glob_and_literal_prefix_contract(self): ++ cases = [ ++ ('cat/pkg/file', ['cat/pkg'], True), ++ ('cat/pkg-other/file', ['cat/pkg'], False), ++ ('cat/pkg/file', ['/cat/pkg/'], True), ++ ('cat/pkg/files/patch', ['cat/*/files'], False), ++ ('cat/pkg/files/patch', ['cat/*/files/*'], True), ++ ('cat/*/files/patch', ['cat/*/files'], True), ++ ('cat/pkg/version-2.ebuild', ['cat/pkg/version-?.ebuild'], True), ++ ('cat/pkg/version-12.ebuild', ['cat/pkg/version-?.ebuild'], False), ++ ('cat/Pkg', ['cat/pkg'], False), ++ ('cat/[broken', ['cat/[broken'], True), ++ ('cat/pkg2', ['cat/pkg[!1]'], True), ++ ('cat/pkg1', ['cat/pkg[!1]'], False), ++ ('cat/a+b', ['cat/a+b'], True), ++ ('cat/ab', ['cat/a+b'], False), ++ ('cat/pkg', ['', '/'], False), ++ ('/', ['*'], False), ++ ('.', ['*'], False), ++ ] ++ for path, patterns, expected in cases: ++ with self.subTest(path=path, patterns=patterns): ++ self.assertEqual(rel_filtered(path, patterns), expected) ++ ++ def test_mutated_filter_list_does_not_reuse_obsolete_rules(self): ++ rules = ['cat/old'] ++ self.assertTrue(rel_filtered('cat/old/file', rules)) ++ rules[:] = ['cat/new'] ++ self.assertFalse(rel_filtered('cat/old/file', rules)) ++ self.assertTrue(rel_filtered('cat/new/file', rules)) ++ ++ def test_compiled_rules_agree_with_previous_matcher(self): ++ rules = ['cat/pkg', 'cat/a*', 'cat/[ab]?', 'cat/name[', 'cat/é', ++ 'cat/line\nbreak', 'cat/*/files', '/other/', 'pkg-?.ebuild'] ++ names = ['pkg', 'pkg-other', 'abc', 'a1', 'b2', 'name[', 'é', ++ 'line\nbreak', '*', 'pkg-2.ebuild', 'pkg-12.ebuild'] ++ for name in names: ++ for prefix in ['', 'cat/', 'other/']: ++ for suffix in ['', '/files', '/files/patch']: ++ path = prefix + name + suffix ++ expected = any(path == rule.strip('/') or ++ path.startswith(rule.strip('/') + '/') or ++ fnmatch.fnmatchcase(path, rule.strip('/')) for rule in rules) ++ self.assertEqual(rel_filtered(path, rules), expected, path) ++ ++ ++class RepositoryTreeTests(unittest.TestCase): ++ def setUp(self): ++ self.temp = TemporaryDirectory() ++ self.addCleanup(self.temp.cleanup) ++ self.root = Path(self.temp.name) / 'tree' ++ self.root.mkdir() ++ ++ def fixture(self): ++ p = self.root ++ (p / 'a').mkdir(); (p / 'a').chmod(0o755) ++ (p / 'a/nested').mkdir(); (p / 'a/nested').chmod(0o700) ++ (p / 'a/value').write_bytes(b'A\r\n'); (p / 'a/value').chmod(0o750) ++ (p / 'z').write_bytes(b'\x00\xff'); (p / 'z').chmod(0o644) ++ (p / 'é').write_bytes(b'unicode'); (p / 'é').chmod(0o444) ++ (p / 'link').symlink_to('a/value') ++ (p / '.git').mkdir(); (p / '.git/config').write_text('ignored') ++ ++ def test_existing_digest_format_is_preserved(self): ++ self.fixture() ++ self.assertEqual(tree_digest(self.root), 'f6285874533e15e8f7099022a9f6d4ec3212e0b882eb117fe0767e86bbee10f6') ++ (self.root / '.git/config').write_text('still ignored') ++ self.root.chmod(0o700) # the containing directory is outside the digest ++ self.assertEqual(tree_digest(self.root), 'f6285874533e15e8f7099022a9f6d4ec3212e0b882eb117fe0767e86bbee10f6') ++ ++ def test_bytes_modes_and_link_targets_remain_integrity_inputs(self): ++ self.fixture() ++ original = tree_digest(self.root) ++ file = self.root / 'a/value' ++ file.write_bytes(b'B\r\n') ++ self.assertNotEqual(tree_digest(self.root), original) ++ file.write_bytes(b'A\r\n') ++ file.chmod(0o700) ++ self.assertNotEqual(tree_digest(self.root), original) ++ file.chmod(0o750) ++ (self.root / 'link').unlink() ++ (self.root / 'link').symlink_to('z') ++ self.assertNotEqual(tree_digest(self.root), original) ++ ++ def test_escaping_links_are_rejected_before_any_copy(self): ++ (self.root / 'first').write_text('package') ++ (self.root / 'outside').symlink_to(self.root.parent) ++ destination = self.root.parent / 'copy' ++ for check in [tree_digest, validate_tree]: ++ with self.assertRaisesRegex(ValueError, 'escapes'): ++ check(self.root) ++ with self.assertRaisesRegex(ValueError, 'escapes'): ++ overlay(self.root, destination) ++ self.assertFalse(destination.exists()) ++ ++ def test_special_files_are_rejected_before_any_copy(self): ++ os.mkfifo(self.root / 'pipe') ++ destination = self.root.parent / 'copy' ++ for check in [tree_digest, validate_tree]: ++ with self.assertRaisesRegex(ValueError, 'unsupported'): ++ check(self.root) ++ with self.assertRaisesRegex(ValueError, 'unsupported'): ++ overlay(self.root, destination) ++ self.assertFalse(destination.exists()) diff --git a/app-admin/osforge/osforge-0.4.0-r2.ebuild b/app-admin/osforge/osforge-0.4.0-r2.ebuild new file mode 100644 index 000000000000..cec5d44d85b7 --- /dev/null +++ b/app-admin/osforge/osforge-0.4.0-r2.ebuild @@ -0,0 +1,58 @@ +# Copyright 2026 Srcux Authors +# Distributed under the terms of the GNU General Public License v2 + +EAPI=8 +DISTUTILS_USE_PEP517=setuptools +PYTHON_COMPAT=( python3_{13..14} ) +inherit distutils-r1 + +DESCRIPTION="Shared Foundry and Crucible runtime with OSForge compatibility" +HOMEPAGE="https://srcux.com/" +SRC_URI="https://www.srcux.com/distfiles/${P}.tar.gz" +LICENSE="MIT" +SLOT="0" +KEYWORDS="~amd64 ~arm64" +IUSE="usb-writer" + +RDEPEND=" + ${PYTHON_DEPS} + acct-group/osforge + app-arch/tar + dev-python/prompt-toolkit[${PYTHON_USEDEP}] + dev-vcs/git + net-misc/rsync + sys-apps/portage + usb-writer? ( + sys-apps/gptfdisk + sys-block/parted + sys-boot/grub + sys-fs/dosfstools + sys-fs/e2fsprogs + ) +" +BDEPEND="test? ( app-arch/tar dev-vcs/git net-misc/rsync )" + +PATCHES=( "${FILESDIR}/osforge-0.4.0-crucible-update.patch" ) + +distutils_enable_tests unittest + +python_test() { + eunittest -s tests +} + +python_install_all() { + distutils-r1_python_install_all + newinitd src/osforge/data/openrc/osforged osforged + newconfd src/osforge/data/openrc/osforged.confd osforged + dodoc -r examples docs + if use usb-writer; then + dosbin scripts/osforge-write-usb + fi +} + +pkg_postinst() { + elog "Foundry and Crucible share the OSForge runtime and existing job history." + elog "Existing configurations and roots are adopted in place." + elog "Start the compatible daemon with: rc-service osforged start" + elog "Enable it at boot with: rc-update add osforged default" +} |
