Files
computer-playbook/tests/unit/cli/core/test_discovery.py
T
kevinveenbirkenbachandClaude Opus 4.7 0639fb2fa7 fix(deploy): unblock matrix-deploy of web-apps on dev runtime
Four independent bugs were each blocking the autonomous
fresh-purged-apps iteration of web-app-{openwebui,wordpress,nextcloud,
phpmyadmin,yourls}; bundling them so the pipeline keeps running.

- cli/core/discovery.py: stop walking argv prefixes at the first flag
  or path-shaped arg and ignore OSError so the matrix-init's
  `infinito create inventory <abs-path> --vars '<huge JSON>'` no longer
  hits ENAMETOOLONG inside `is_dir()`. New regression tests pin both.
- utils/cache/data.py: when this module moved to utils/cache/data.py,
  PROJECT_ROOT silently slid one level deep, pointing ROLES_DIR at a
  non-existent `<repo>/utils/roles`. Implicit lookups returned empty
  defaults so `lookup('users','contact')` failed at deploy time as if
  the user weren't defined. Use parents[2] and pin the invariant in a
  new regression test.
- roles/web-app-keycloak/vars/main.yml: KEYCLOAK_PER_APP_LDAP_ROLES_MAPPER
  inherited `subComponents`/`id` from the realm-import dictionary's base
  `ldap-roles` mapper. kcadm rejects those keys on `create components`
  ("Unrecognized field 'subComponents'"), with stderr swallowed by
  `2>/dev/null` in upsert_app_mapper.sh. Strip both keys exactly like
  update/05_ldap.yml already does for the shared mapper.
- scripts/tests/deploy/local/deploy/fresh-purged-app.sh: pass
  `RUNTIME=dev` through `--vars`, otherwise the host process running
  init falls back to `detect_runtime() == "host"` and the Playwright
  E2E gate (`RUNTIME in [dev, act, github]`) never fires.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 17:17:14 +02:00

119 lines
5.1 KiB
Python

import tempfile
import unittest
from pathlib import Path
from cli.core.discovery import discover_commands, resolve_command_module
class TestDiscovery(unittest.TestCase):
def _touch(self, path: Path, content: str = "") -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
def test_discover_commands_finds_packages_with_main(self):
with tempfile.TemporaryDirectory() as td:
cli_dir = Path(td) / "cli"
cli_dir.mkdir(parents=True, exist_ok=True)
# Dispatcher exists but must NOT be treated as a command
self._touch(cli_dir / "__main__.py", "# dispatcher\n")
# commands
self._touch(cli_dir / "deploy" / "container" / "__main__.py", "# cmd\n")
self._touch(cli_dir / "build" / "tree" / "__main__.py", "# cmd\n")
self._touch(cli_dir / "make" / "__main__.py", "# cmd\n")
cmds = discover_commands(cli_dir)
modules = {c.module for c in cmds}
self.assertIn("cli.deploy.container", modules)
self.assertIn("cli.build.tree", modules)
self.assertIn("cli.make", modules)
self.assertNotIn("cli", modules) # root must not be a command
def test_discover_commands_ignores_pycache(self):
with tempfile.TemporaryDirectory() as td:
cli_dir = Path(td) / "cli"
cli_dir.mkdir(parents=True, exist_ok=True)
self._touch(cli_dir / "__main__.py", "# dispatcher\n")
pycache = cli_dir / "x" / "__pycache__"
self._touch(pycache / "__main__.py", "# should be ignored\n")
cmds = discover_commands(cli_dir)
modules = {c.module for c in cmds}
self.assertNotIn("cli.x.__pycache__", modules)
def test_resolve_command_module_longest_prefix_wins(self):
with tempfile.TemporaryDirectory() as td:
cli_dir = Path(td) / "cli"
cli_dir.mkdir(parents=True, exist_ok=True)
# Both deploy and deploy/container exist -> prefer deploy/container
self._touch(cli_dir / "__main__.py", "# dispatcher\n")
self._touch(cli_dir / "deploy" / "__main__.py", "# cmd\n")
self._touch(cli_dir / "deploy" / "container" / "__main__.py", "# cmd\n")
module, remaining = resolve_command_module(
cli_dir, ["deploy", "container", "--foo", "bar"]
)
self.assertEqual(module, "cli.deploy.container")
self.assertEqual(remaining, ["--foo", "bar"])
def test_resolve_command_module_returns_none_when_not_found(self):
with tempfile.TemporaryDirectory() as td:
cli_dir = Path(td) / "cli"
cli_dir.mkdir(parents=True, exist_ok=True)
self._touch(cli_dir / "__main__.py", "# dispatcher\n")
module, remaining = resolve_command_module(cli_dir, ["nope", "--x"])
self.assertIsNone(module)
self.assertEqual(remaining, ["nope", "--x"])
def test_resolve_command_module_stops_at_first_flag(self):
# `infinito create inventory <abs-path> --host localhost ...`: the
# subcommand is `create inventory`; the absolute positional arg
# MUST NOT be tried as a sub-package, otherwise pathlib resets
# the search root and downstream args (huge `--vars` JSON) end
# up concatenated as a single path that hits ENAMETOOLONG.
with tempfile.TemporaryDirectory() as td:
cli_dir = Path(td) / "cli"
cli_dir.mkdir(parents=True, exist_ok=True)
self._touch(cli_dir / "__main__.py", "# dispatcher\n")
self._touch(cli_dir / "create" / "inventory" / "__main__.py", "# cmd\n")
argv = [
"create",
"inventory",
"/home/user/inventories/localhost-0",
"--host",
"localhost",
"--vars",
'{"a": 1, "b": 2}',
]
module, remaining = resolve_command_module(cli_dir, argv)
self.assertEqual(module, "cli.create.inventory")
self.assertEqual(remaining, argv[2:])
def test_resolve_command_module_survives_oserror_on_long_path(self):
# Defence in depth: even if a freshly-introduced positional arg
# somehow slips past the flag-stop heuristic and produces a path
# that stat() refuses (ENAMETOOLONG, ENOENT, EACCES), the
# dispatcher MUST keep trying shorter prefixes instead of crashing.
with tempfile.TemporaryDirectory() as td:
cli_dir = Path(td) / "cli"
cli_dir.mkdir(parents=True, exist_ok=True)
self._touch(cli_dir / "__main__.py", "# dispatcher\n")
self._touch(cli_dir / "build" / "tree" / "__main__.py", "# cmd\n")
# 5000-char arg blows past most filesystem name-length limits.
blob = "x" * 5000
argv = ["build", "tree", blob]
module, remaining = resolve_command_module(cli_dir, argv)
self.assertEqual(module, "cli.build.tree")
self.assertEqual(remaining, [blob])
if __name__ == "__main__":
unittest.main()