Add support for detecting flatpaks and snaps
Some checks failed
CI / test (push) Failing after 5m51s
Lint / test (push) Successful in 43s

This commit is contained in:
Miguel Jacq 2026-06-14 18:25:26 +10:00
parent 11351cce87
commit eb1d096c90
Signed by: mig5
GPG key ID: 03906B4110AAD3B8
10 changed files with 2033 additions and 16 deletions

View file

@ -312,3 +312,185 @@ def test_parse_group_handles_short_lines(tmp_path: Path):
assert 1000 in gid_to_name
assert 1001 not in gid_to_name # skipped due to short line
assert 1002 in gid_to_name
def test_find_flatpaks_in_root_detects_remote_branch_and_arch(tmp_path: Path):
import enroll.accounts as a
root = tmp_path / "flatpak"
(root / "repo").mkdir(parents=True)
(root / "repo" / "config").write_text(
'[remote "acme"]\nurl=https://flatpak.example/repo/\n',
encoding="utf-8",
)
ref = (
root
/ "repo"
/ "refs"
/ "remotes"
/ "acme"
/ "app"
/ "com.example.App"
/ "x86_64"
/ "stable"
)
ref.parent.mkdir(parents=True)
ref.write_text("checksum\n", encoding="utf-8")
active = root / "app" / "com.example.App" / "x86_64" / "stable" / "active"
active.mkdir(parents=True)
remotes = a.find_flatpak_remotes(str(root), method="system")
assert [(r.name, r.url, r.method) for r in remotes] == [
("acme", "https://flatpak.example/repo/", "system")
]
apps = a._find_flatpaks_in_root(str(root), method="system")
assert len(apps) == 1
assert apps[0].name == "com.example.App"
assert apps[0].remote == "acme"
assert apps[0].branch == "stable"
assert apps[0].arch == "x86_64"
def test_parse_snap_list_output_detects_channel_revision_and_modes():
import enroll.accounts as a
output = """Name Version Rev Tracking Publisher Notes
code abc 123 latest/stable vscode classic
mydev 1.0 42 latest/edge example devmode,dangerous
bare 1.0 5 latest/stable canonical base
"""
snaps = {snap.name: snap for snap in a._parse_snap_list_output(output)}
assert snaps["code"].channel == "latest/stable"
assert snaps["code"].revision == 123
assert snaps["code"].classic is True
assert snaps["mydev"].devmode is True
assert snaps["mydev"].dangerous is True
assert snaps["bare"].notes == ["base"]
def test_parse_flatpak_list_output_detects_system_refs():
from enroll.accounts import _parse_flatpak_list_output
output = "\n".join(
[
"app/org.example.App/x86_64/stable\tflathub\tstable\tx86_64",
"runtime/org.freedesktop.Platform/x86_64/24.08\tflathub\t24.08\tx86_64",
]
)
refs = _parse_flatpak_list_output(
output, method="system", columns=("ref", "origin", "branch", "arch")
)
assert [(r.kind, r.name, r.remote, r.branch, r.arch) for r in refs] == [
("app", "org.example.App", "flathub", "stable", "x86_64"),
("runtime", "org.freedesktop.Platform", "flathub", "24.08", "x86_64"),
]
assert refs[0].source == "flatpak-list"
def test_find_system_flatpaks_prefers_flatpak_list(monkeypatch):
import subprocess
import enroll.accounts as a
calls = []
def fake_run(args, **kwargs):
calls.append(args)
if args == ["flatpak", "list", "--columns=help"]:
return subprocess.CompletedProcess(
args,
0,
stdout="application\norigin\nbranch\narch\n",
stderr="",
)
return subprocess.CompletedProcess(
args,
0,
stdout="app/org.example.App/x86_64/stable\tacme\tstable\tx86_64\n",
stderr="",
)
monkeypatch.setattr(a.shutil, "which", lambda cmd: "/usr/bin/flatpak")
monkeypatch.setattr(a.subprocess, "run", fake_run)
monkeypatch.setattr(
a,
"_find_flatpaks_in_root",
lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("fallback used")),
)
refs = a.find_system_flatpaks()
assert calls[0] == ["flatpak", "list", "--columns=help"]
assert calls[1][:3] == ["flatpak", "list", "--system"]
assert refs[0].name == "org.example.App"
assert refs[0].method == "system"
assert refs[0].remote == "acme"
def test_parse_flatpak_list_output_detects_application_columns():
from enroll.accounts import _parse_flatpak_list_output
output = "org.example.App\tflathub\tstable\tx86_64\n"
refs = _parse_flatpak_list_output(
output, method="system", columns=("application", "origin", "branch", "arch")
)
assert len(refs) == 1
assert refs[0].name == "org.example.App"
assert refs[0].kind is None
assert refs[0].remote == "flathub"
assert refs[0].branch == "stable"
assert refs[0].arch == "x86_64"
def test_parse_plain_flatpak_list_output_like_default_table():
from enroll.accounts import _parse_flatpak_list_output
output = """Name Application ID Version Branch Installation
Mesa org.freedesktop.Platform.GL.default 26.0.6 25.08 system
Mesa (Extra) org.freedesktop.Platform.GL.default 26.0.6 25.08-extra system
Codecs Extra Extension org.freedesktop.Platform.codecs-extra 25.08-extra system
KDE Application Platform org.kde.Platform 6.10 system
OnionShare org.onionshare.OnionShare 2.6.4 stable system
"""
refs = _parse_flatpak_list_output(output, method="system", columns=None)
by_name_branch = {(r.name, r.branch) for r in refs}
assert ("org.onionshare.OnionShare", "stable") in by_name_branch
assert ("org.freedesktop.Platform.GL.default", "25.08") in by_name_branch
assert ("org.freedesktop.Platform.GL.default", "25.08-extra") in by_name_branch
assert ("org.kde.Platform", "6.10") in by_name_branch
def test_parse_flatpak_columns_help_handles_description_table():
from enroll.accounts import _parse_flatpak_columns_help
output = """
Available columns:
application The application ID
branch The branch
installation The installation
"""
assert _parse_flatpak_columns_help(output) >= {
"application",
"branch",
"installation",
}
def test_flatpak_list_attempts_respect_supported_columns():
from enroll.accounts import _flatpak_list_attempts
attempts = _flatpak_list_attempts(
"--system", {"application", "branch", "installation"}
)
command_strings = [" ".join(args) for args, _columns in attempts]
assert any("--columns=application,branch" in cmd for cmd in command_strings)
assert not any("origin" in cmd for cmd in command_strings)
assert command_strings[-1] == "flatpak list --system"

View file

@ -224,6 +224,19 @@ def test_harvest_dedup_manual_packages_and_builds_etc_custom(
monkeypatch.setattr(harvest, "collect_non_system_users", lambda: [])
import enroll.accounts as accounts
monkeypatch.setattr(accounts, "find_system_flatpaks", lambda: [])
monkeypatch.setattr(accounts, "find_system_flatpak_remotes", lambda: [])
monkeypatch.setattr(
accounts, "find_user_flatpak_remotes", lambda home, user=None: []
)
monkeypatch.setattr(
accounts,
"find_system_snaps",
lambda: [accounts.SnapInstall(name="code", channel="latest/stable")],
)
def fake_stat_triplet(p: str):
if p == "/usr/local/bin/myscript":
return ("root", "root", "0755")
@ -259,6 +272,9 @@ def test_harvest_dedup_manual_packages_and_builds_etc_custom(
for o in openvpn_obs
)
assert st["roles"]["snap"]["role_name"] == "snap"
assert st["roles"]["snap"]["system_snaps"][0]["name"] == "code"
# Service role captured modified conffile
svc = st["roles"]["services"][0]
assert svc["unit"] == "openvpn.service"

View file

@ -286,3 +286,20 @@ def test_collect_firewall_runtime_snapshot_is_per_family_fallback(
assert (
tmp_path / "artifacts" / "firewall_runtime" / "firewall" / "iptables.v6"
).exists()
def test_package_role_names_do_not_collide_with_singleton_roles():
from enroll.harvest import _role_name_from_pkg
assert _role_name_from_pkg("flatpak") == "package_flatpak"
assert _role_name_from_pkg("snap") == "package_snap"
assert _role_name_from_pkg("users") == "package_users"
assert _role_name_from_pkg("nginx") == "nginx"
def test_service_role_names_do_not_collide_with_singleton_roles():
from enroll.harvest import _role_name_from_unit
assert _role_name_from_unit("flatpak.service") == "service_flatpak"
assert _role_name_from_unit("users.service") == "service_users"
assert _role_name_from_unit("nginx.service") == "nginx"

View file

@ -1064,3 +1064,317 @@ def test_render_firewall_runtime_tasks_with_ipv6():
}
result = manifest._render_firewall_runtime_tasks(state)
assert len(result) >= 1
def test_manifest_renders_flatpak_and_snap_details(tmp_path: Path):
bundle = tmp_path / "bundle"
out = tmp_path / "ansible"
state = {
"schema_version": 3,
"host": {"hostname": "test", "os": "debian", "pkg_backend": "dpkg"},
"inventory": {"packages": {}},
"roles": {
"users": {
"role_name": "users",
"users": [
{
"name": "alice",
"uid": 1000,
"gid": 1000,
"gecos": "Alice",
"home": "/home/alice",
"shell": "/bin/bash",
"primary_group": "alice",
"supplementary_groups": [],
}
],
"managed_files": [],
"excluded": [],
"notes": [],
"user_flatpak_remotes": [
{
"name": "acme-user",
"method": "user",
"url": "https://flatpak.example/user-repo/",
"user": "alice",
"home": "/home/alice",
},
],
"user_flatpaks": {
"alice": [
{
"name": "org.example.UserApp",
"method": "user",
"remote": "acme-user",
"branch": "stable",
"arch": "x86_64",
}
]
},
},
"flatpak": {
"role_name": "flatpak",
"remotes": [
{
"name": "acme",
"method": "system",
"url": "https://flatpak.example/repo/",
},
],
"system_flatpaks": [
{
"name": "com.example.App",
"method": "system",
"remote": "acme",
"branch": "stable",
"arch": "x86_64",
}
],
"notes": [],
},
"snap": {
"role_name": "snap",
"system_snaps": [
{
"name": "code",
"channel": "latest/stable",
"revision": 123,
"classic": True,
"notes": ["classic"],
}
],
"notes": [],
},
"services": [],
"packages": [],
"apt_config": {
"role_name": "apt_config",
"managed_files": [],
"excluded": [],
"notes": [],
},
"dnf_config": {
"role_name": "dnf_config",
"managed_files": [],
"excluded": [],
"notes": [],
},
"etc_custom": {
"role_name": "etc_custom",
"managed_files": [],
"excluded": [],
"notes": [],
},
"usr_local_custom": {
"role_name": "usr_local_custom",
"managed_files": [],
"excluded": [],
"notes": [],
},
"extra_paths": {
"role_name": "extra_paths",
"include_patterns": [],
"exclude_patterns": [],
"managed_files": [],
"excluded": [],
"notes": [],
},
},
}
bundle.mkdir(parents=True, exist_ok=True)
(bundle / "state.json").write_text(json.dumps(state, indent=2), encoding="utf-8")
manifest.manifest(str(bundle), str(out))
users_defaults = (out / "roles" / "users" / "defaults" / "main.yml").read_text(
encoding="utf-8"
)
users_tasks = (out / "roles" / "users" / "tasks" / "main.yml").read_text(
encoding="utf-8"
)
users_readme = (out / "roles" / "users" / "README.md").read_text(encoding="utf-8")
flatpak_defaults = (out / "roles" / "flatpak" / "defaults" / "main.yml").read_text(
encoding="utf-8"
)
flatpak_tasks = (out / "roles" / "flatpak" / "tasks" / "main.yml").read_text(
encoding="utf-8"
)
snap_defaults = (out / "roles" / "snap" / "defaults" / "main.yml").read_text(
encoding="utf-8"
)
snap_tasks = (out / "roles" / "snap" / "tasks" / "main.yml").read_text(
encoding="utf-8"
)
assert "users_flatpak_remotes:" in users_defaults
assert "remote: acme-user" in users_defaults
assert "community.general.snap" not in users_tasks
assert "Install system-wide snaps" not in users_tasks
assert "Install system-wide Flatpaks" not in users_tasks
assert "ansible-galaxy collection install -r requirements.yml" in users_readme
assert "snap_system_snaps:" in snap_defaults
assert "channel: latest/stable" in snap_defaults
assert "classic: true" in snap_defaults
assert "community.general.snap" in snap_tasks
assert "Install system-wide snaps with full detected attributes" in snap_tasks
assert "Install system-wide snaps with compatibility options" in snap_tasks
assert "Install system-wide snaps with minimal options" in snap_tasks
assert "ignore_errors: true" in snap_tasks
assert "flatpak_system_flatpaks:" in flatpak_defaults
assert "remote: acme" in flatpak_defaults
assert "community.general.flatpak" in flatpak_tasks
assert "Install system-wide Flatpaks" in flatpak_tasks
assert (out / "requirements.yml").exists()
def test_users_role_without_portable_apps_omits_community_general_tasks(tmp_path):
bundle = tmp_path / "bundle"
out = tmp_path / "out"
state = {
"roles": {
"users": {
"role_name": "users",
"users": [
{
"name": "alice",
"uid": 1000,
"gid": 1000,
"gecos": "Alice",
"home": "/home/alice",
"shell": "/bin/bash",
"primary_group": "alice",
"supplementary_groups": [],
}
],
"managed_files": [],
"excluded": [],
"notes": [],
},
"services": [],
"packages": [],
},
}
bundle.mkdir(parents=True, exist_ok=True)
(bundle / "state.json").write_text(json.dumps(state, indent=2), encoding="utf-8")
manifest.manifest(str(bundle), str(out))
users_tasks = (out / "roles" / "users" / "tasks" / "main.yml").read_text(
encoding="utf-8"
)
users_meta = (out / "roles" / "users" / "meta" / "main.yml").read_text(
encoding="utf-8"
)
assert "community.general.flatpak" not in users_tasks
assert "community.general.snap" not in users_tasks
assert "collections:" not in users_meta
def test_manifest_emits_flatpak_role_even_when_no_flatpaks(tmp_path):
bundle = tmp_path / "bundle"
out = tmp_path / "out"
state = {
"roles": {
"users": {
"role_name": "users",
"users": [],
"managed_files": [],
"excluded": [],
"notes": [],
"user_flatpaks": {},
"user_flatpak_remotes": [],
},
"flatpak": {
"role_name": "flatpak",
"system_flatpaks": [],
"remotes": [],
"notes": [],
},
"services": [],
"packages": [],
}
}
bundle.mkdir(parents=True, exist_ok=True)
(bundle / "state.json").write_text(json.dumps(state, indent=2), encoding="utf-8")
manifest.manifest(str(bundle), str(out))
flatpak_tasks = (out / "roles" / "flatpak" / "tasks" / "main.yml").read_text(
encoding="utf-8"
)
flatpak_defaults = (out / "roles" / "flatpak" / "defaults" / "main.yml").read_text(
encoding="utf-8"
)
assert "flatpak_system_flatpaks: []" in flatpak_defaults
assert "flatpak_remotes: []" in flatpak_defaults
assert "Install system-wide Flatpaks" in flatpak_tasks
assert "Ensure system Flatpak remotes exist" in flatpak_tasks
def test_manifest_avoids_package_role_collision_with_flatpak_singleton(tmp_path):
bundle = tmp_path / "bundle"
out = tmp_path / "out"
state = {
"roles": {
"users": {
"role_name": "users",
"users": [],
"managed_files": [],
"excluded": [],
"notes": [],
"user_flatpaks": {},
"user_flatpak_remotes": [],
},
"flatpak": {
"role_name": "flatpak",
"remotes": [
{
"name": "flathub",
"method": "system",
"url": "https://dl.flathub.org/repo/",
}
],
"system_flatpaks": [
{
"name": "org.onionshare.OnionShare",
"method": "system",
"remote": "flathub",
"branch": "stable",
"arch": "x86_64",
}
],
"notes": [],
},
"services": [],
"packages": [
{
"package": "flatpak",
"role_name": "flatpak",
"managed_files": [],
"managed_dirs": [],
"managed_links": [],
"excluded": [],
"notes": [],
"has_config": True,
}
],
}
}
bundle.mkdir(parents=True, exist_ok=True)
(bundle / "state.json").write_text(json.dumps(state, indent=2), encoding="utf-8")
manifest.manifest(str(bundle), str(out))
flatpak_defaults = (out / "roles" / "flatpak" / "defaults" / "main.yml").read_text(
encoding="utf-8"
)
playbook = (out / "playbook.yml").read_text(encoding="utf-8")
assert "org.onionshare.OnionShare" in flatpak_defaults
assert (out / "roles" / "package_flatpak" / "tasks" / "main.yml").exists()
assert "role: flatpak" in playbook
assert "role: package_flatpak" in playbook