diff --git a/.github/workflows/branch-e2e.yml b/.github/workflows/branch-e2e.yml index fe8dfbed02..ceb62b860d 100644 --- a/.github/workflows/branch-e2e.yml +++ b/.github/workflows/branch-e2e.yml @@ -462,8 +462,7 @@ jobs: secrets: CACHIX_AUTH_TOKEN: ${{ secrets.CACHIX_AUTH_TOKEN }} - # Run the Podman e2e suite on tmachine. It needs the binary installer's CLI - # path and local HTTP gateway. + # Run the Podman e2e suite on tmachine against the packaged gateway. podman-e2e-integration: needs: prepare-integration permissions: @@ -477,7 +476,7 @@ jobs: integration-inputs-artifact-id: ${{ needs.prepare-integration.outputs.integration_inputs_artifact_id }} test-matrix: >- [ - {"environment":"fedora-podman-rootless","installer":"binaries","testsuite":"e2e-podman"} + {"environment":"fedora-podman-rootless","installer":"rpm","testsuite":"e2e-podman"} ] docker-e2e: diff --git a/.github/workflows/e2e-suites.yml b/.github/workflows/e2e-suites.yml index e91a146d2e..7c995a198b 100644 --- a/.github/workflows/e2e-suites.yml +++ b/.github/workflows/e2e-suites.yml @@ -91,14 +91,14 @@ jobs: {"environment":"ubuntu-k3s","installer":"k3s-ha","family":"features","package":"host-gateway","gateway":"tmachine","runner":"ubuntu-24.04"}, {"environment":"fedora-podman-rootful-keycloak","installer":"rpm","family":"features","package":"keycloak-provider-refresh","gateway":"openshell","runner":"ubuntu-24.04"}, {"environment":"fedora-podman-rootless-keycloak","installer":"rpm","family":"features","package":"keycloak-provider-refresh","gateway":"openshell","runner":"ubuntu-24.04"}, - {"environment":"fedora-podman-rootful-userns-default","installer":"binaries","family":"drivers/podman","gateway":"tmachine","runner":"ubuntu-24.04"}, - {"environment":"fedora-podman-rootful-userns-auto","installer":"binaries","family":"drivers/podman","gateway":"tmachine","runner":"ubuntu-24.04"}, - {"environment":"fedora-podman-rootful-userns-keep-id","installer":"binaries","family":"drivers/podman","gateway":"tmachine","runner":"ubuntu-24.04"}, - {"environment":"fedora-podman-rootful-userns-private","installer":"binaries","family":"drivers/podman","gateway":"tmachine","runner":"ubuntu-24.04"}, - {"environment":"fedora-podman-rootless-userns-default","installer":"binaries","family":"drivers/podman","gateway":"tmachine","runner":"ubuntu-24.04"}, - {"environment":"fedora-podman-rootless-userns-auto","installer":"binaries","family":"drivers/podman","gateway":"tmachine","runner":"ubuntu-24.04"}, - {"environment":"fedora-podman-rootless-userns-keep-id","installer":"binaries","family":"drivers/podman","gateway":"tmachine","runner":"ubuntu-24.04"}, - {"environment":"fedora-podman-rootless-userns-private","installer":"binaries","family":"drivers/podman","gateway":"tmachine","runner":"ubuntu-24.04"} + {"environment":"fedora-podman-rootful-userns-default","installer":"rpm","family":"drivers/podman","gateway":"openshell","runner":"ubuntu-24.04"}, + {"environment":"fedora-podman-rootful-userns-auto","installer":"rpm","family":"drivers/podman","gateway":"openshell","runner":"ubuntu-24.04"}, + {"environment":"fedora-podman-rootful-userns-keep-id","installer":"rpm","family":"drivers/podman","gateway":"openshell","runner":"ubuntu-24.04"}, + {"environment":"fedora-podman-rootful-userns-private","installer":"rpm","family":"drivers/podman","gateway":"openshell","runner":"ubuntu-24.04"}, + {"environment":"fedora-podman-rootless-userns-default","installer":"rpm","family":"drivers/podman","gateway":"openshell","runner":"ubuntu-24.04"}, + {"environment":"fedora-podman-rootless-userns-auto","installer":"rpm","family":"drivers/podman","gateway":"openshell","runner":"ubuntu-24.04"}, + {"environment":"fedora-podman-rootless-userns-keep-id","installer":"rpm","family":"drivers/podman","gateway":"openshell","runner":"ubuntu-24.04"}, + {"environment":"fedora-podman-rootless-userns-private","installer":"rpm","family":"drivers/podman","gateway":"openshell","runner":"ubuntu-24.04"} ]' # `suite` names the row's tests in the job and artifact names. matrix=$(jq -c --arg family "${FAMILY}" '{include: map( diff --git a/.github/workflows/integration-runner.yml b/.github/workflows/integration-runner.yml index 906a60e0cb..f84811bdd9 100644 --- a/.github/workflows/integration-runner.yml +++ b/.github/workflows/integration-runner.yml @@ -24,7 +24,7 @@ on: type: string default: >- [ - {"environment":"fedora-podman-rootless","installer":"binaries","testsuite":"e2e-podman"} + {"environment":"fedora-podman-rootless","installer":"rpm","testsuite":"e2e-podman"} ] permissions: diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml index 5efe0bd4e1..961e7b3c22 100644 --- a/.github/workflows/integration-test.yml +++ b/.github/workflows/integration-test.yml @@ -27,7 +27,7 @@ on: type: string default: >- [ - {"environment":"fedora-podman-rootless","installer":"binaries","testsuite":"e2e-podman"} + {"environment":"fedora-podman-rootless","installer":"rpm","testsuite":"e2e-podman"} ] permissions: diff --git a/CI.md b/CI.md index 977b160f1e..6dac0a4bf5 100644 --- a/CI.md +++ b/CI.md @@ -166,10 +166,11 @@ selected. DEB and RPM packages build only when integration qualification is sele the DEB on Ubuntu with Docker and installs the CLI and gateway RPMs on Fedora with rootful and rootless Podman. These lanes run conformance using the matching runtime images. Release Dev, Release, and Pre-release use the same package installers. -The feature suites (`tests/suites/features`) run the same way, on the -package installers. The Podman driver suite (`tests/suites/drivers/podman`) and the -`e2e-podman` lane retain the binary installer because they configure its system -service, local HTTP gateway, and CLI path. The driver suite and `e2e-podman` run +The feature suites (`tests/suites/features`) and the Podman driver suite +(`tests/suites/drivers/podman`) run the same way, on the package installers; the +driver suite's environments merge their Podman settings into the packaged +gateway's configuration (see `TESTING.md`). The `e2e-podman` lane runs on the +RPM installer as well. The driver suite and `e2e-podman` run only in `Branch E2E Checks`. The manual Integration Tests workflow runs the remaining tmachine testsuites, such as `e2e-podman`, and downloads the packages selected by its matrix. diff --git a/TESTING.md b/TESTING.md index f49be898a5..2250118e6e 100644 --- a/TESTING.md +++ b/TESTING.md @@ -541,7 +541,7 @@ Run the portable subset in a disposable rootless Podman guest: ```shell nix run .#build-artifacts -nix run .#tmachine -- test fedora-podman-rootless binaries e2e-podman +nix run .#tmachine -- test fedora-podman-rootless rpm e2e-podman ``` Print the exact tmachine archive selection as a shell `PODMAN_CI_TESTS` array: @@ -566,14 +566,40 @@ matching lines in `branch-checks.yml`, and `ALIGNED_WORKSPACES`. The Podman driver suite (`tests/suites/drivers/podman`) compares OpenShell and direct Podman user-namespace mappings. Each `fedora-podman-{rootful,rootless}-userns-` environment configures -the gateway with one mode (`default`, `auto`, `keep-id` or `private`); only the -`binaries` installer applies it: +the gateway with one mode (`default`, `auto`, `keep-id` or `private`) and works +with every installer; CI runs it with `rpm`: ```shell nix run .#openshell-test-runner -- run \ - --environment fedora-podman-rootless-userns-keep-id --installer binaries \ - --gateway tmachine --family drivers/podman -``` + --environment fedora-podman-rootless-userns-keep-id --installer rpm \ + --family drivers/podman +``` + +#### Gateway overrides in an environment + +An environment overrides gateway settings by writing the tables to change to +`/etc/openshell-test/gateway-overrides.toml` in a playbook that runs before the +installer. The file holds whole tables, such as `[openshell.gateway]` or +`[openshell.drivers.podman]`, and is not a complete configuration. Each +installer applies it to the gateway's own file: + +- `binaries` applies it to the `/etc/openshell/gateway.toml` it generates. +- `deb` and `rpm` apply it to the service user's + `~/.config/openshell/gateway.toml`, the file the packaged service reads. The + file starts as a copy of the rpm's + `/usr/share/openshell-gateway/gateway.toml.default`, where the service would + seed it on first start, or as a minimal `version = 2` file when the package + has no default. No environment replaces the packaged file, and an environment + with no overrides leaves it untouched. +- `k3s` installers ignore the file. + +`tests/tmachine/ansible/roles/openshell_gateway/files/merge-gateway-toml.py` +does the work. Each key in the overrides replaces the same key in the table, or +is added to it, and a table the file lacks is appended. The rest of the file, +comments included, stays. The script parses the result and fails the setup, +leaving the file unchanged, when it isn't valid TOML. It can't remove a key. +Settings that the packaged service reads from `gateway.env` instead of the +TOML are not covered. The `oci-image` feature suite (`tests/suites/features/oci-image`) checks OCI image identity and working-directory behavior shared by the Docker and Podman diff --git a/tasks/test.toml b/tasks/test.toml index bbdddc8bd8..e586c13748 100644 --- a/tasks/test.toml +++ b/tasks/test.toml @@ -26,8 +26,13 @@ depends = [ "test:maintainer-approval", "test:cargo-lockfile-alignment", "test:review-readiness", + "test:merge-gateway-toml", ] +["test:merge-gateway-toml"] +description = "Test the tmachine gateway TOML merge script" +run = "uv run --no-project --with pytest pytest tests/tmachine/ansible/roles/openshell_gateway/files/merge_gateway_toml_test.py" + ["test:cargo-lockfile-alignment"] description = "Test the Cargo lockfile alignment check" run = "uv run --no-project --with pytest pytest tasks/scripts/check_cargo_lockfile_alignment_test.py" diff --git a/tests/tmachine/ansible/playbooks/drivers/podman/e2e.yaml b/tests/tmachine/ansible/playbooks/drivers/podman/e2e.yaml index e839a2cfff..a9270a0922 100644 --- a/tests/tmachine/ansible/playbooks/drivers/podman/e2e.yaml +++ b/tests/tmachine/ansible/playbooks/drivers/podman/e2e.yaml @@ -48,13 +48,30 @@ HOME: /home/tmachine XDG_RUNTIME_DIR: /run/user/1000 + # The binaries installer runs a plain-HTTP gateway named tmachine from a + # system service and puts the CLI in /usr/local/bin. The package installers + # run the packaged user service, install the CLI in /usr/bin and register + # the gateway as openshell, using the package default network and namespace. + - name: Detect the installer + ansible.builtin.stat: + path: /usr/local/bin/openshell + register: podman_e2e_binaries_cli + + - name: Select the gateway under test + ansible.builtin.set_fact: + podman_e2e_binaries: "{{ podman_e2e_binaries_cli.stat.exists }}" + podman_e2e_cli: "{{ '/usr/local/bin/openshell' if podman_e2e_binaries_cli.stat.exists else '/usr/bin/openshell' }}" + podman_e2e_gateway: "{{ 'tmachine' if podman_e2e_binaries_cli.stat.exists else 'openshell' }}" + - name: Remove any previous OpenShell gateway registration + when: podman_e2e_binaries ansible.builtin.command: argv: [/usr/local/bin/openshell, gateway, remove, tmachine] changed_when: false failed_when: false - name: Register the configured OpenShell gateway + when: podman_e2e_binaries ansible.builtin.command: argv: [/usr/local/bin/openshell, gateway, add, http://127.0.0.1:17670, --local, --name, tmachine] @@ -75,12 +92,12 @@ CONTAINER_ENGINE: podman CONTAINER_HOST: unix:///run/user/1000/podman/podman.sock HOME: /home/tmachine - OPENSHELL_BIN: /usr/local/bin/openshell + OPENSHELL_BIN: "{{ podman_e2e_cli }}" OPENSHELL_E2E_CONTAINER_ENGINE_UNSET_XDG_CONFIG_HOME: "1" OPENSHELL_E2E_DRIVER: podman - OPENSHELL_E2E_NETWORK_NAME: tmachine - OPENSHELL_E2E_SANDBOX_NAMESPACE: tmachine - OPENSHELL_GATEWAY: tmachine + OPENSHELL_E2E_NETWORK_NAME: "{{ podman_e2e_gateway }}" + OPENSHELL_E2E_SANDBOX_NAMESPACE: "{{ podman_e2e_gateway }}" + OPENSHELL_GATEWAY: "{{ podman_e2e_gateway }}" OPENSHELL_PODMAN_SOCKET: /run/user/1000/podman/podman.sock XDG_RUNTIME_DIR: /run/user/1000 register: podman_e2e_test_result @@ -96,9 +113,12 @@ var: podman_e2e_test_result.stdout_lines - name: Capture OpenShell gateway journal after Podman E2E test failure - become: true + become: "{{ podman_e2e_binaries }}" ansible.builtin.command: - argv: [journalctl, --unit, openshell-gateway.service, --no-pager, --lines, "200"] + argv: >- + {{ ['journalctl', '--unit', 'openshell-gateway.service', '--no-pager', '--lines', '200'] + if podman_e2e_binaries else + ['journalctl', '--user-unit', 'openshell-gateway.service', '--no-pager', '--lines', '200'] }} changed_when: false failed_when: false when: podman_e2e_test_result.rc != 0 diff --git a/tests/tmachine/ansible/playbooks/drivers/podman/userns.yaml b/tests/tmachine/ansible/playbooks/drivers/podman/userns.yaml index 89022bd769..202270100f 100644 --- a/tests/tmachine/ansible/playbooks/drivers/podman/userns.yaml +++ b/tests/tmachine/ansible/playbooks/drivers/podman/userns.yaml @@ -5,8 +5,9 @@ # # `podman_userns_mode` is default, auto, keep-id or private. Every mode except # default writes its `[openshell.drivers.podman]` settings to -# `podman_userns_driver_config`, which the openshell_gateway role (the -# `binaries` installer) appends to gateway.toml; default removes the file. The +# `/etc/openshell-test/gateway-overrides.toml`, which every installer applies to the active +# gateway-overrides.toml (the openshell_gateway role for `binaries`, the +# openshell_packaged_gateway role for the packages); default removes the file. The # Podman support crate (tests/support/podman) reads the published mode and # runs Podman's own reference container in it. --- @@ -14,14 +15,17 @@ hosts: all gather_facts: false vars: - podman_userns_driver_config: /etc/openshell-test/podman-driver.toml + podman_userns_driver_config: /etc/openshell-test/gateway-overrides.toml podman_userns_settings: default: "" auto: | + [openshell.drivers.podman] userns = "auto" keep-id: | + [openshell.drivers.podman] userns = "keep-id" private: | + [openshell.drivers.podman] userns = "private" uidmap = ["0:0:1", "1:1:65535"] gidmap = ["0:0:1", "1:1:65535"] @@ -59,7 +63,7 @@ when: podman_userns_mode != "default" # The fragment is the only source of userns, uidmap and gidmap settings in - # the rendered gateway.toml, so default mode leaves them unset. + # the gateway's configuration, so default mode leaves them unset. - name: Remove Podman driver user-namespace settings for default mode become: true ansible.builtin.file: diff --git a/tests/tmachine/ansible/roles/openshell_gateway/files/merge-gateway-toml.py b/tests/tmachine/ansible/roles/openshell_gateway/files/merge-gateway-toml.py new file mode 100755 index 0000000000..02ef6e874c --- /dev/null +++ b/tests/tmachine/ansible/roles/openshell_gateway/files/merge-gateway-toml.py @@ -0,0 +1,165 @@ +#!/usr/bin/python3 +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Apply an environment's gateway overrides to a gateway.toml. + +usage: merge-gateway-toml.py BASE OVERRIDES [--default DEFAULT] + +OVERRIDES holds whole tables, such as `[openshell.gateway]` or +`[openshell.drivers.podman]`. Each key in it replaces the same key in BASE's +table, or is added to the table, and a table BASE lacks is appended. Everything +else in BASE stays, including its comments, so a package's own configuration is +never replaced. Running it again with the same overrides changes nothing. + +If BASE does not exist it is created first: from DEFAULT when that file exists +(the rpm's packaged default), else as a minimal `version = 2` file. The merged +text is parsed as TOML before it is written, and the script fails, leaving BASE +as it was, if it is not valid. + +The overrides must be valid TOML, and the script fails if any of their values is +missing from the merged result, so a statement it cannot rewrite is an error +rather than a silent no-op. + +Limits: a key's value in BASE is replaced as written lines, up to the next key, +header, comment or blank line. A key can't be removed. +""" + +import argparse +import os +import re +import sys +import tomllib + +HEADER = re.compile(r"^\s*\[([^\[\]]+)\]\s*(#.*)?$") +ARRAY_HEADER = re.compile(r"^\s*\[\[[^\[\]]+\]\]\s*(#.*)?$") +KEY = re.compile(r"^\s*([A-Za-z0-9_-]+|\"[^\"]+\"|'[^']+')\s*=") +MINIMAL = "[openshell]\nversion = 2\n" + + +def table_name(line): + return re.sub(r"\s+", "", HEADER.match(line).group(1)) + + +def is_boundary(line): + """A table or array-of-tables header ends the current table.""" + return bool(HEADER.match(line) or ARRAY_HEADER.match(line)) + + +def key_name(raw): + return raw[1:-1] if raw[0] in "\"'" else raw + + +def is_statement_start(line): + return bool(is_boundary(line) or KEY.match(line)) + + +def is_break(line): + stripped = line.strip() + return not stripped or stripped.startswith("#") + + +def statements(lines): + """The (key, lines) pairs of a table body, in order.""" + found = [] + for line in lines: + key = KEY.match(line) + if key: + found.append((key_name(key.group(1)), [line])) + elif found and not is_break(line): + found[-1][1].append(line) + return found + + +def override_tables(text): + if any(line.lstrip().startswith("[[") for line in text.splitlines()): + sys.exit("array-of-tables headers are not supported in the overrides") + tables, current = {}, None + for line in text.splitlines(): + if HEADER.match(line): + current = table_name(line) + tables.setdefault(current, []) + elif current is not None: + tables[current].append(line) + elif not is_break(line): + sys.exit(f"override key outside a table: {line!r}") + return {name: statements(body) for name, body in tables.items()} + + +def apply(lines, table, new): + start = next( + (i for i, l in enumerate(lines) if HEADER.match(l) and table_name(l) == table), + None, + ) + if start is None: + return lines + ["", f"[{table}]", *(l for _, stmt in new for l in stmt)] + for key, stmt in new: + end = next( + (i for i in range(start + 1, len(lines)) if is_boundary(lines[i])), + len(lines), + ) + existing = next( + ( + i + for i in range(start + 1, end) + if (m := KEY.match(lines[i])) and key_name(m.group(1)) == key + ), + None, + ) + if existing is None: + lines[start + 1 : start + 1] = stmt + continue + stop = existing + 1 + while stop < end and not is_statement_start(lines[stop]) and not is_break(lines[stop]): + stop += 1 + lines[existing:stop] = stmt + return lines + + +def contains(merged, expected): + """Whether every table and value in EXPECTED is present in MERGED.""" + if isinstance(expected, dict): + return isinstance(merged, dict) and all( + k in merged and contains(merged[k], v) for k, v in expected.items() + ) + return merged == expected + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("base") + parser.add_argument("overrides") + parser.add_argument("--default") + args = parser.parse_args() + + created = not os.path.exists(args.base) + if not created: + text = open(args.base).read() + elif args.default and os.path.exists(args.default): + text = open(args.default).read() + else: + text = MINIMAL + lines = text.splitlines() + override_text = open(args.overrides).read() + try: + wanted = tomllib.loads(override_text) + except tomllib.TOMLDecodeError as error: + sys.exit(f"{args.overrides} is not valid TOML: {error}") + for table, new in override_tables(override_text).items(): + lines = apply(lines, table, new) + merged = "\n".join(lines) + "\n" + try: + result = tomllib.loads(merged) + except tomllib.TOMLDecodeError as error: + sys.exit(f"merged {args.base} is not valid TOML: {error}") + # The rewriter works on lines, so confirm every override really landed + # rather than trusting it to have handled each statement. + if not contains(result, wanted): + sys.exit(f"the overrides in {args.overrides} were not all applied to {args.base}") + with open(args.base, "w") as out: + out.write(merged) + if created: + os.chmod(args.base, 0o644) + + +main() diff --git a/tests/tmachine/ansible/roles/openshell_gateway/files/merge_gateway_toml_test.py b/tests/tmachine/ansible/roles/openshell_gateway/files/merge_gateway_toml_test.py new file mode 100644 index 0000000000..ba6926169a --- /dev/null +++ b/tests/tmachine/ansible/roles/openshell_gateway/files/merge_gateway_toml_test.py @@ -0,0 +1,100 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +import subprocess +import sys +import tomllib +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).with_name("merge-gateway-toml.py") +BASE = '[openshell]\nversion = 2\n\n[openshell.drivers.podman]\n# keep\nhealth_check_interval_secs = 10\n' + + +def merge(tmp_path, base, overrides, default=None): + base_file, override_file = tmp_path / "gateway.toml", tmp_path / "overrides.toml" + if base is not None: + base_file.write_text(base) + override_file.write_text(overrides) + args = [sys.executable, str(SCRIPT), str(base_file), str(override_file)] + if default is not None: + default_file = tmp_path / "default.toml" + default_file.write_text(default) + args += ["--default", str(default_file)] + run = subprocess.run(args, capture_output=True, text=True) + return run, base_file + + +def test_replaces_and_preserves_comments(tmp_path): + run, base = merge(tmp_path, BASE, '[openshell.drivers.podman]\nhealth_check_interval_secs = 15\nuserns = "keep-id"\n') + assert run.returncode == 0, run.stderr + text = base.read_text() + assert "# keep" in text + assert tomllib.loads(text)["openshell"]["drivers"]["podman"] == { + "health_check_interval_secs": 15, + "userns": "keep-id", + } + + +def test_idempotent(tmp_path): + overrides = '[openshell.drivers.podman]\nuserns = "private"\nuidmap = ["0:0:1",\n "1:1:65535"]\n' + merge(tmp_path, BASE, overrides) + first = (tmp_path / "gateway.toml").read_text() + run, base = merge(tmp_path, first, overrides) + assert run.returncode == 0 and base.read_text() == first + + +def test_array_of_tables_header_is_a_boundary(tmp_path): + base = ( + "[openshell.drivers.podman]\nhealth_check_interval_secs = 10\n" + '[[openshell.supervisor.middleware]]\nname = "local-guard"\n' + 'grpc_endpoint = "https://127.0.0.1:50051"\n' + ) + run, out = merge(tmp_path, base, "[openshell.drivers.podman]\nhealth_check_interval_secs = 15\n") + assert run.returncode == 0, run.stderr + parsed = tomllib.loads(out.read_text())["openshell"] + assert parsed["drivers"]["podman"] == {"health_check_interval_secs": 15} + assert parsed["supervisor"]["middleware"] == [ + {"name": "local-guard", "grpc_endpoint": "https://127.0.0.1:50051"} + ] + + +@pytest.mark.parametrize("quote", ['"', "'"]) +def test_quoted_key_is_applied(tmp_path, quote): + run, base = merge(tmp_path, BASE, f'[openshell.drivers.podman]\n{quote}userns{quote} = "keep-id"\n') + assert run.returncode == 0, run.stderr + assert tomllib.loads(base.read_text())["openshell"]["drivers"]["podman"]["userns"] == "keep-id" + + +@pytest.mark.parametrize( + "overrides", + [ + '[openshell.drivers.podman]\nuserns "keep-id"\n', + '[openshell.drivers.podman]\nuserns = "keep-id\n', + '[[openshell.supervisor.middleware]]\nname = "x"\n', + 'userns = "keep-id"\n', + ], +) +def test_rejects_unusable_overrides_and_leaves_base(tmp_path, overrides): + run, base = merge(tmp_path, BASE, overrides) + assert run.returncode != 0 + assert base.read_text() == BASE + + +def test_dotted_key_not_silently_dropped(tmp_path): + run, base = merge(tmp_path, BASE, "[openshell.drivers]\npodman.userns = \"auto\"\n") + if run.returncode == 0: + assert tomllib.loads(base.read_text())["openshell"]["drivers"]["podman"]["userns"] == "auto" + else: + assert base.read_text() == BASE + + +def test_creates_from_default_else_minimal(tmp_path): + run, base = merge(tmp_path, None, '[openshell.drivers.podman]\nuserns = "auto"\n', default=BASE) + assert run.returncode == 0 and "health_check_interval_secs = 10" in base.read_text() + other = tmp_path / "other" + other.mkdir() + run, base = merge(other, None, '[openshell.drivers.podman]\nuserns = "auto"\n') + parsed = tomllib.loads(base.read_text()) + assert parsed["openshell"]["version"] == 2 diff --git a/tests/tmachine/ansible/roles/openshell_gateway/tasks/main.yaml b/tests/tmachine/ansible/roles/openshell_gateway/tasks/main.yaml index c8089f5ae0..2d23c352c3 100644 --- a/tests/tmachine/ansible/roles/openshell_gateway/tasks/main.yaml +++ b/tests/tmachine/ansible/roles/openshell_gateway/tasks/main.yaml @@ -58,6 +58,11 @@ dest: /etc/openshell/gateway.toml mode: "0644" +- name: Merge environment gateway settings + ansible.builtin.include_tasks: merge-settings.yaml + vars: + openshell_gateway_merge_target: /etc/openshell/gateway.toml + - name: Install OpenShell gateway service become: true ansible.builtin.template: diff --git a/tests/tmachine/ansible/roles/openshell_gateway/tasks/merge-settings.yaml b/tests/tmachine/ansible/roles/openshell_gateway/tasks/merge-settings.yaml new file mode 100644 index 0000000000..6d167a3ec8 --- /dev/null +++ b/tests/tmachine/ansible/roles/openshell_gateway/tasks/merge-settings.yaml @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# An environment overrides gateway settings by writing the tables to change, +# such as `[openshell.drivers.podman]`, to +# /etc/openshell-test/gateway-overrides.toml before the installer runs (for +# example playbooks/drivers/podman/userns.yaml). Apply them to +# `openshell_gateway_merge_target` without replacing the file; see +# files/merge-gateway-toml.py. +# +# `openshell_gateway_merge_default` is a default configuration to start from +# when the target doesn't exist yet, and `openshell_gateway_merge_user` is who +# owns the target. +--- +- name: Check for environment gateway overrides + ansible.builtin.stat: + path: /etc/openshell-test/gateway-overrides.toml + register: openshell_gateway_overrides + +- name: Apply environment gateway overrides + become: true + become_user: "{{ openshell_gateway_merge_user | default('root') }}" + ansible.builtin.script: + cmd: >- + merge-gateway-toml.py {{ openshell_gateway_merge_target }} + /etc/openshell-test/gateway-overrides.toml + {{ '--default ' ~ openshell_gateway_merge_default + if openshell_gateway_merge_default | default('') else '' }} + executable: /usr/bin/python3 + when: openshell_gateway_overrides.stat.exists diff --git a/tests/tmachine/ansible/roles/openshell_gateway/tasks/podman.yaml b/tests/tmachine/ansible/roles/openshell_gateway/tasks/podman.yaml index b85361b769..b47c7a65dd 100644 --- a/tests/tmachine/ansible/roles/openshell_gateway/tasks/podman.yaml +++ b/tests/tmachine/ansible/roles/openshell_gateway/tasks/podman.yaml @@ -36,22 +36,3 @@ environment: HOME: "{{ openshell_gateway_home }}" XDG_RUNTIME_DIR: "/run/user/{{ openshell_gateway_uid }}" - -# An environment can add [openshell.drivers.podman] settings, such as the -# user-namespace mode (playbooks/drivers/podman/userns.yaml). -- name: Check for environment Podman driver settings - ansible.builtin.stat: - path: /etc/openshell-test/podman-driver.toml - register: openshell_gateway_podman_driver_file - -- name: Read environment Podman driver settings - ansible.builtin.slurp: - src: /etc/openshell-test/podman-driver.toml - register: openshell_gateway_podman_driver_slurp - when: openshell_gateway_podman_driver_file.stat.exists - -- name: Select environment Podman driver settings - ansible.builtin.set_fact: - openshell_gateway_podman_driver_settings: >- - {{ openshell_gateway_podman_driver_slurp.content | b64decode | trim - if openshell_gateway_podman_driver_file.stat.exists else '' }} diff --git a/tests/tmachine/ansible/roles/openshell_gateway/templates/gateway-podman.toml.j2 b/tests/tmachine/ansible/roles/openshell_gateway/templates/gateway-podman.toml.j2 index 0ee292ea29..e59314f632 100644 --- a/tests/tmachine/ansible/roles/openshell_gateway/templates/gateway-podman.toml.j2 +++ b/tests/tmachine/ansible/roles/openshell_gateway/templates/gateway-podman.toml.j2 @@ -8,7 +8,4 @@ image_pull_policy = "if_not_present" network_name = "tmachine" health_check_interval_secs = 10 stop_timeout_secs = 15 -{% if openshell_gateway_podman_driver_settings %} -{{ openshell_gateway_podman_driver_settings }} -{% endif %} {% endblock %} diff --git a/tests/tmachine/ansible/roles/openshell_packaged_gateway/tasks/main.yaml b/tests/tmachine/ansible/roles/openshell_packaged_gateway/tasks/main.yaml index 155eaa44c8..43db126598 100644 --- a/tests/tmachine/ansible/roles/openshell_packaged_gateway/tasks/main.yaml +++ b/tests/tmachine/ansible/roles/openshell_packaged_gateway/tasks/main.yaml @@ -21,17 +21,6 @@ name: openshell_gateway tasks_from: "{{ openshell_gateway_driver }}.yaml" -# The packaged gateway's configuration comes from its environment file, so it -# can't apply an environment's Podman driver settings. Fail rather than test a -# gateway without them. -- name: Require no environment Podman driver settings - ansible.builtin.assert: - that: - - not (openshell_gateway_podman_driver_settings | default('')) - fail_msg: >- - This environment configures Podman driver settings, which only the - binaries installer applies. - - name: Create packaged gateway environment directory become: true ansible.builtin.file: @@ -56,6 +45,21 @@ OPENSHELL_SANDBOX_RUNTIME_IMAGE=docker.io/openshell/sandbox:tmachine OPENSHELL_SUPERVISOR_IMAGE=docker.io/openshell/supervisor:tmachine +# Override settings in the package's own configuration. The rpm seeds +# ~/.config/openshell/gateway.toml from its default on first start, which comes +# after this, so start from that default here and the package's seeding then +# leaves the file alone. A package with no default, such as the deb, gets a +# minimal file; its driver comes from gateway.env. An environment with no +# overrides leaves the packaged configuration untouched. +- name: Apply environment gateway overrides to the packaged gateway + ansible.builtin.include_role: + name: openshell_gateway + tasks_from: merge-settings.yaml + vars: + openshell_gateway_merge_target: "{{ openshell_gateway_home }}/.config/openshell/gateway.toml" + openshell_gateway_merge_default: /usr/share/openshell-gateway/gateway.toml.default + openshell_gateway_merge_user: "{{ openshell_gateway_user }}" + - name: Start gateway user manager ansible.builtin.include_role: name: tmachine_user_manager diff --git a/tests/tmachine/config.nix b/tests/tmachine/config.nix index 2b715726aa..8a8a798176 100644 --- a/tests/tmachine/config.nix +++ b/tests/tmachine/config.nix @@ -71,7 +71,8 @@ let ) podmanModes; # fedora-podman-{rootful,rootless}-userns-{default,auto,keep-id,private} # configure the Podman gateway's user-namespace mode, for the Podman driver - # suite. Only the binaries installer applies the mode. + # suite. Every installer applies the mode through + # /etc/openshell-test/gateway-overrides.toml. podmanUsernsEnvironments = pkgs.lib.concatMap ( podman: map (