diff --git a/.github/workflows/test_scripts.yml b/.github/workflows/test_scripts.yml new file mode 100644 index 0000000..384d241 --- /dev/null +++ b/.github/workflows/test_scripts.yml @@ -0,0 +1,41 @@ +name: Scripts Tests + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + paths: + - 'scripts/**' + - 'users.yml' + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + cd scripts + pip install -r requirements-test.txt + + - name: Run tests with coverage + run: | + cd scripts + pytest tests/ -v --cov=. --cov-report=xml --cov-report=term + + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v4 + with: + file: ./scripts/coverage.xml + flags: scripts + fail_ci_if_error: false + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/pyproject.toml b/pyproject.toml index 2ecc45e..bc45d01 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [tool.pytest.ini_options] -testpaths = ["tests"] +testpaths = ["tests", "scripts/tests"] python_files = ["test_*.py"] python_classes = ["Test*"] python_functions = ["test_*"] @@ -17,7 +17,7 @@ addopts = """ # `pytest-timeout` if you need per-test timeouts. [tool.coverage.run] -source = ["tests"] +source = ["tests", "scripts"] omit = [ "*/venv/*", "*/__pycache__/*", diff --git a/scripts/generate_config.py b/scripts/generate_config.py index a902a8f..0552419 100644 --- a/scripts/generate_config.py +++ b/scripts/generate_config.py @@ -4,6 +4,7 @@ Usage: python scripts/generate_config.py [--users-file PATH] [--repo-root PATH] + [--ssh-keys-dir PATH] Run this script after editing users.yml, then commit all changed files. The deployed system never runs this generator; it only reads the outputs. @@ -76,14 +77,14 @@ def _validate(users: list[dict]) -> None: # --------------------------------------------------------------------------- -def _sftp_volumes_entry(user: dict) -> list[str]: +def _sftp_volumes_entry(user: dict, ssh_keys_dir: str) -> list[str]: """Named-volume and host-key-mount entries for one user.""" lines = [] for src in user["sources"]: vol = f"sftp_{user['id']}_{src['id']}" lines.append(f" - {vol}:/home/{user['id']}/uploads/{src['id']}") lines.append( - f" - ./ssh_keys/{user['id']}/authorized_keys" + f" - {ssh_keys_dir}/{user['id']}/authorized_keys" f":/home/{user['id']}/.ssh/keys/authorized_keys:ro" ) return lines @@ -132,7 +133,7 @@ def _parser_service(user: dict, src: dict) -> str: ) -def generate_compose_override(users: list[dict]) -> str: +def generate_compose_override(users: list[dict], ssh_keys_dir: str = "./ssh_keys") -> str: lines = [ "# docker-compose.override.yml", "# AUTO-GENERATED by scripts/generate_config.py — do not edit by hand.", @@ -149,7 +150,7 @@ def generate_compose_override(users: list[dict]) -> str: lines.append(f" command: [{_sftp_command_args(users)}]") lines.append(" volumes:") for user in users: - lines.extend(_sftp_volumes_entry(user)) + lines.extend(_sftp_volumes_entry(user, ssh_keys_dir)) lines.append("") @@ -475,7 +476,7 @@ def ensure_ssh_keys(users: list[dict], ssh_keys_dir: Path) -> None: for u in users: uid = u["id"] key_dir = ssh_keys_dir / uid - key_dir.mkdir(exist_ok=True) + key_dir.mkdir(parents=True, exist_ok=True) priv_key = key_dir / "id_ed25519" auth_keys = key_dir / "authorized_keys" @@ -530,14 +531,34 @@ def main(argv: list[str] | None = None) -> None: default=None, help="Repository root directory (default: directory of this script's parent)", ) + parser.add_argument( + "--ssh-keys-dir", + default=None, + help=( + "Directory that holds per-user SSH key subdirectories " + "(default: /ssh_keys). The path is written verbatim " + "into docker-compose.override.yml as the host side of the " + "authorized_keys bind-mount, so a relative path like ../ssh_keys " + "is resolved by Docker Compose relative to the override file." + ), + ) args = parser.parse_args(argv) script_dir = Path(__file__).resolve().parent repo_root = Path(args.repo_root) if args.repo_root else script_dir.parent users_file = Path(args.users_file) if args.users_file else repo_root / "users.yml" + # ssh_keys_dir for key generation is always an absolute/real path; + # ssh_keys_mount is the string written into the compose file (may be relative). + if args.ssh_keys_dir: + ssh_keys_mount = args.ssh_keys_dir.rstrip("/") + ssh_keys_dir = (repo_root / ssh_keys_mount).resolve() + else: + ssh_keys_mount = "./ssh_keys" + ssh_keys_dir = repo_root / "ssh_keys" print(f"Repository root : {repo_root}") print(f"Users file : {users_file}") + print(f"SSH keys dir : {ssh_keys_dir} (mount path: {ssh_keys_mount})") print() users = load_users(users_file) @@ -546,7 +567,7 @@ def main(argv: list[str] | None = None) -> None: # 1. docker-compose.override.yml override_path = repo_root / "docker-compose.override.yml" - override_path.write_text(generate_compose_override(users)) + override_path.write_text(generate_compose_override(users, ssh_keys_mount)) print(f" [compose] Written {override_path.relative_to(repo_root)}") # 2. sftp_receiver/entrypoint.sh @@ -579,7 +600,6 @@ def main(argv: list[str] | None = None) -> None: print(f" [grafana] Updated {init_grafana_path.relative_to(repo_root)}") # 7. SSH keys - ssh_keys_dir = repo_root / "ssh_keys" ensure_ssh_keys(users, ssh_keys_dir) print() diff --git a/scripts/requirements-test.txt b/scripts/requirements-test.txt new file mode 100644 index 0000000..9a62f48 --- /dev/null +++ b/scripts/requirements-test.txt @@ -0,0 +1,3 @@ +pytest>=7.4.0 +pytest-cov>=4.1.0 +pyyaml>=6.0 diff --git a/scripts/tests/test_generate_config.py b/scripts/tests/test_generate_config.py new file mode 100644 index 0000000..83e9802 --- /dev/null +++ b/scripts/tests/test_generate_config.py @@ -0,0 +1,160 @@ +"""Tests for scripts/generate_config.py — focused on the --ssh-keys-dir option.""" + +from pathlib import Path + +import pytest +import yaml + +# Import helpers directly from the script. +import sys + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from generate_config import ( + generate_compose_override, + ensure_ssh_keys, + main, +) + + +# --------------------------------------------------------------------------- +# Minimal users fixture +# --------------------------------------------------------------------------- + +USERS = [ + { + "id": "alice", + "uid": 2001, + "display_name": "Alice", + "grafana_org_id": 1, + "sources": [{"id": "main", "parser": "openmrg"}], + } +] + + +def _make_users_yml(repo_root: Path, users: list[dict] | None = None) -> Path: + data = {"users": users or USERS} + p = repo_root / "users.yml" + p.write_text(yaml.dump(data)) + return p + + +# --------------------------------------------------------------------------- +# generate_compose_override — ssh_keys_dir param +# --------------------------------------------------------------------------- + + +def test_default_ssh_keys_dir_uses_dot_slash(): + """Without --ssh-keys-dir the mount path is ./ssh_keys//...""" + output = generate_compose_override(USERS) + assert "./ssh_keys/alice/authorized_keys" in output + + +def test_custom_ssh_keys_dir_written_verbatim(): + """A relative path like ../ssh_keys is written as-is into the compose file.""" + output = generate_compose_override(USERS, ssh_keys_dir="../ssh_keys") + assert "../ssh_keys/alice/authorized_keys" in output + + + +def test_absolute_ssh_keys_dir_written_verbatim(tmp_path): + """An absolute path is also written verbatim.""" + abs_path = str(tmp_path / "keys") + output = generate_compose_override(USERS, ssh_keys_dir=abs_path) + assert f"{abs_path}/alice/authorized_keys" in output + + +# --------------------------------------------------------------------------- +# ensure_ssh_keys — uses the resolved absolute path for key generation +# --------------------------------------------------------------------------- + + +def test_ensure_ssh_keys_creates_keys_in_given_dir(tmp_path): + """Keys are generated under the supplied directory, not under repo_root.""" + keys_dir = tmp_path / "external_keys" + ensure_ssh_keys(USERS, keys_dir) + + priv = keys_dir / "alice" / "id_ed25519" + pub = keys_dir / "alice" / "id_ed25519.pub" + auth = keys_dir / "alice" / "authorized_keys" + + assert priv.exists(), "private key not generated" + assert pub.exists(), "public key not generated" + assert auth.exists(), "authorized_keys not created" + + +def test_ensure_ssh_keys_skips_existing_key(tmp_path): + """Existing private key is left untouched (no overwrite).""" + keys_dir = tmp_path / "keys" + user_dir = keys_dir / "alice" + user_dir.mkdir(parents=True) + priv = user_dir / "id_ed25519" + priv.write_text("EXISTING") + + ensure_ssh_keys(USERS, keys_dir) + assert priv.read_text() == "EXISTING" + + +# --------------------------------------------------------------------------- +# main() — end-to-end with --ssh-keys-dir +# --------------------------------------------------------------------------- + + +def test_main_ssh_keys_dir_override_uses_external_dir(tmp_path, monkeypatch): + """ + Running main() with --ssh-keys-dir writes ../ssh_keys paths into the + generated compose override and places keys in the external directory. + """ + # Build a minimal repo layout inside tmp_path + repo_root = tmp_path / "repo" + for subdir in ("sftp_receiver", "webserver/configs", "database/migrations", + "grafana/provisioning/datasources", "grafana", "ssh_keys"): + (repo_root / subdir).mkdir(parents=True, exist_ok=True) + + # Minimal users.yml (deployment-level, one real user) + _make_users_yml(tmp_path) # tmp_path/users.yml + + # Stub out files that main() reads/updates + (repo_root / "webserver" / "configs" / "users.json").write_text("{}") + (repo_root / "grafana" / "init_grafana.py").write_text( + "ORGS = []\nUSERS = []\n" + ) + + # External ssh_keys dir lives next to the repo (simulating deployment layout) + ext_keys_dir = tmp_path / "ssh_keys" + ext_keys_dir.mkdir() + + main([ + "--users-file", str(tmp_path / "users.yml"), + "--repo-root", str(repo_root), + "--ssh-keys-dir", str(ext_keys_dir), + ]) + + # 1. Compose override uses the external path + override = (repo_root / "docker-compose.override.yml").read_text() + assert str(ext_keys_dir) + "/alice/authorized_keys" in override + + # 2. Keys were generated in the external directory + assert (ext_keys_dir / "alice" / "id_ed25519").exists() + + # 3. Default ssh_keys inside repo_root was NOT used + assert not (repo_root / "ssh_keys" / "alice").exists() + + +def test_main_default_ssh_keys_dir_stays_inside_repo(tmp_path): + """Without --ssh-keys-dir, keys go into /ssh_keys as before.""" + repo_root = tmp_path / "repo" + for subdir in ("sftp_receiver", "webserver/configs", "database/migrations", + "grafana/provisioning/datasources", "grafana", "ssh_keys"): + (repo_root / subdir).mkdir(parents=True, exist_ok=True) + + _make_users_yml(repo_root) + (repo_root / "webserver" / "configs" / "users.json").write_text("{}") + (repo_root / "grafana" / "init_grafana.py").write_text( + "ORGS = []\nUSERS = []\n" + ) + + main(["--repo-root", str(repo_root)]) + + override = (repo_root / "docker-compose.override.yml").read_text() + assert "./ssh_keys/alice/authorized_keys" in override + assert (repo_root / "ssh_keys" / "alice" / "id_ed25519").exists()