Commit 8599cacf authored by Konstantinos Togias's avatar Konstantinos Togias
Browse files

ci: qualify disposable platform health on user-namespaced runners

parent e3bee420
Loading
Loading
Loading
Loading
Loading
+4 −1
Changes for .gitlab-ci.yml: 4 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -122,10 +122,13 @@ platform-lab:
    DOCKER_TLS_VERIFY: "1"
    DOCKER_CERT_PATH: /certs/client
  rules:
    - if: '$CI_COMMIT_BRANCH == "test/platform-lab"'
      when: manual
      allow_failure: false
    - if: '$RUN_PLATFORM_LAB == "1" && $CI_COMMIT_REF_PROTECTED == "true"'
    - when: never
  script:
    - sh ci/run_platform_lab.sh "$OOP_LAB_PROFILE" .cache/platform-lab
    - sh ci/run_platform_lab.sh "${OOP_LAB_PROFILE:-ci/lab/profile.health.json}" .cache/platform-lab
  artifacts:
    when: always
    paths:
+13 −1
Changes for ansible/tests/unit/test_platform_lab.py: 13 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -27,10 +27,14 @@ def test_ci_rejects_unpinned_images_and_local_exceptions():


@pytest.mark.parametrize("failure", [None, "cluster", "deploy", "identity", "health", "cleanup", "existing"])
def test_owned_lab_lifecycle_propagates_failure_and_preserves_existing_clusters(tmp_path, failure):
@pytest.mark.parametrize("uid_map", ["         0          0 4294967295\n", "0 100000 65536\n"])
def test_owned_lab_lifecycle_propagates_failure_and_preserves_existing_clusters(tmp_path, monkeypatch, failure, uid_map):
    name = "oop-ci-123-1234abcd"
    (tmp_path / "private").mkdir()
    commands = []
    original_read = Path.read_text
    monkeypatch.setattr(Path, "read_text", lambda path, *args, **kwargs:
                        uid_map if str(path) == "/proc/self/uid_map" else original_read(path, *args, **kwargs))

    def command(argv, **kwargs):
        commands.append(argv)
@@ -80,5 +84,13 @@ def test_owned_lab_lifecycle_propagates_failure_and_preserves_existing_clusters(
        assert (tmp_path / "events.txt").exists()
    if failure == "existing":
        assert len(commands) == 1
    else:
        create = next(argv for argv in commands if argv[:3] == ["k3d", "cluster", "create"])
        user_namespace = "100000" in uid_map
        assert ("--kubelet-arg=feature-gates=KubeletInUserNamespace=true@server:0" in create) == user_namespace
        assert ("--kube-proxy-arg=conntrack-max-per-core=0@server:0" in create) == user_namespace
        assert summary["node_mode"] == ("user-namespace" if user_namespace else "initial-namespace")
        assert "--disable=traefik@server:0" in create
        assert create[create.index("--servers-memory") + 1] == "6g"
    if failure == "cleanup":
        assert summary["cleanup"] == "failed"
+20 −2
Changes for ci/lab/README.md: 20 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -16,6 +16,11 @@ The default kubeconfig stays unchanged. Application NodePorts stay inside the
temporary Docker network; the Kubernetes API mapping binds only to loopback.
The controller joins that network.

The checked-in `ci/lab/profile.health.json` selects published OEG, FM and SRM
images for the first health run. SRM is develop commit e326ebe0; its declared
SDK dependency is sunrise6g-opensdk 2.0.0. This baseline proves platform health,
not acceptance of an unpublished SDK candidate. The same profile works locally.

The core profile enables OEG, SRM, FM, Keycloak, PostgreSQL and NATS. Portal,
AI2, Data Lab, federation peers and external network providers remain outside
this first scenario. The existing deployment also waits for chart readiness
@@ -51,8 +56,12 @@ is performed by this command.

## GitLab job

`platform-lab` is opt-in: a protected ref plus `RUN_PLATFORM_LAB=1`.
`OOP_LAB_PROFILE` is a protected File variable containing the reviewed JSON.
`platform-lab` is manual on the bounded `test/platform-lab` trial branch. It uses
the checked-in non-secret health profile and needs no added project credentials.
On protected refs it remains opt-in with `RUN_PLATFORM_LAB=1`.
An optional `OOP_LAB_PROFILE` File variable supplies another reviewed JSON;
keep sensitive profiles protected. The default profile contains image references
only and creates all platform credentials inside the disposable lab.
The Docker-executor runner must permit DinD and share its TLS client
certificates at `/certs/client`, as documented by GitLab. An online runner
or its description alone proves neither capability nor available capacity.
@@ -61,6 +70,15 @@ service's Docker socket, not a mount of the runner host's Docker daemon.
Check runner configuration/capacity before enabling the job. The same wrapper
can run against a developer's local Docker daemon.

When the controller detects a non-initial UID map, the node uses Kubernetes'
experimental user-namespace settings qualified on the runner: the
KubeletInUserNamespace feature, cgroupfs and disabled conntrack host tuning.
See https://kubernetes.io/docs/tasks/administer-cluster/kubelet-in-userns/.
This lab mode has kernel/OOM, volume-driver and CNI limitations; it is not a
production conformance claim. Initial-namespace execution keeps its ordinary
node settings. Both paths require a Ready node and actual platform health.
The k3d tools image is digest-pinned before the node is created.

K3d's generated files use a private Docker volume mounted at the same absolute
path in the controller and daemon. This matters for a remote daemon: binding
the CI workspace path would refer to the DinD filesystem instead of the job.
+9 −0
Changes for ci/lab/profile.health.json: 9 added lines, 0 removed lines.
Original line number Diff line number Diff line
{
  "chart_revision": "1d0dd82231310f083e0a8411a9f81cbe2fc99da2",
  "images": {
    "oeg": "labs.etsi.org:5050/oop/code/open-exposure-gateway:ci-develop-68ff79a6@sha256:f555ea3829af4f192dcc88e818f88077121d60e1eff7b93bc754b21c3fd6dc35",
    "fm": "labs.etsi.org:5050/oop/code/federation-manager:ci-develop-853bb20f@sha256:f41f2d0a972a76f49be074636bb459e1ac244471601900460c883aa5710cf998",
    "srm": "labs.etsi.org:5050/oop/code/service-resource-manager:ci-develop-e326ebe0@sha256:7bc5f4a841e44a07af64791d712765fcf5df37ee3a0222c2be87fb6f1528aa76"
  },
  "local_images": []
}
+38 −5
Changes for ci/platform_lab.py: 38 added lines, 5 removed lines.
Original line number Diff line number Diff line
@@ -13,6 +13,27 @@ import yaml

ROOT = Path(__file__).resolve().parents[1]
K3S = "rancher/k3s:v1.31.4-k3s1@sha256:627fc392e5e7992fb8aa738fb04bc0c9ae9a068999cb4cfbea5858661cd79d04"
K3D_TOOLS = "ghcr.io/k3d-io/k3d-tools@sha256:bb3b76a2eefabefbf058e601605db7d0975fa23c60a06e769b4c5f5a3630bd88"


def node_args(uid_map):
    """Use the qualified experimental node mode only inside a user namespace.

    See https://kubernetes.io/docs/tasks/administer-cluster/kubelet-in-userns/.
    The runner's outer namespace is inherited by the controller and k3d node.
    """
    options = ["--k3s-arg", "--disable=traefik@server:0"]
    if " ".join(uid_map.split()) != "0 0 4294967295":
        for setting in (
            "kubelet-arg=feature-gates=KubeletInUserNamespace=true",
            "kubelet-arg=cgroup-driver=cgroupfs",
            "kube-proxy-arg=feature-gates=KubeletInUserNamespace=true",
            "kube-proxy-arg=conntrack-max-per-core=0",
            "kube-proxy-arg=conntrack-tcp-timeout-established=0s",
            "kube-proxy-arg=conntrack-tcp-timeout-close-wait=0s",
        ):
            options.extend(["--k3s-arg", "--" + setting + "@server:0"])
    return options


def run(argv, **kwargs):
@@ -48,18 +69,26 @@ def exercise(profile, output, name, chart, controller="", call=run):
    owned = False
    loaded = {}
    kubeconfig = output / "private/kubeconfig"
    kubectl = ["kubectl", "--kubeconfig", str(kubeconfig)]
    kubectl = ["kubectl", "--kubeconfig", str(kubeconfig), "--request-timeout=10s"]
    try:
        clusters = json.loads(call(["k3d", "cluster", "list", "-o", "json"],
                                  capture_output=True).stdout)
        if any(cluster["name"] == name for cluster in clusters):
            raise ValueError("Refuse to reuse or delete an existing cluster")
        uid_map = Path("/proc/self/uid_map").read_text()
        options = node_args(uid_map)
        result["node_mode"] = "user-namespace" if len(options) > 2 else "initial-namespace"
        print("Lab node mode: " + result["node_mode"], flush=True)
        # k3d starts its helper by version tag. Pin its bytes before creation.
        call(["docker", "pull", K3D_TOOLS])
        call(["docker", "tag", K3D_TOOLS, "ghcr.io/k3d-io/k3d-tools:5.8.1"])
        owned = True  # Include partial create failures in cleanup; the name was absent.
        result["stage"] = "cluster"
        call(["k3d", "cluster", "create", name, "--image", K3S,
              "--servers-memory", "6g", "--no-lb", "--api-port", "127.0.0.1:0",
              "--servers", "1", "--agents", "0", "--servers-memory", "6g", "--no-lb",
              "--api-port", "127.0.0.1:0",
              "--kubeconfig-update-default=false", "--kubeconfig-switch-context=false",
              "--k3s-arg", "--disable=traefik@server:0", "--timeout", "180s"], timeout=240)
              *options, "--timeout", "180s"], timeout=240)
        network, node = "k3d-" + name, "k3d-" + name + "-server-0"
        if controller:
            call(["docker", "network", "connect", network, controller])
@@ -151,8 +180,12 @@ def exercise(profile, output, name, chart, controller="", call=run):
                                           capture_output=True).stdout)
                networks = call(["docker", "network", "ls", "--filter", f"name=^k3d-{name}$",
                                 "--format", "{{.Name}}"], capture_output=True).stdout.strip()
                if any(cluster["name"] == name for cluster in remaining) or networks:
                    raise ValueError("Owned cluster or network survived cleanup")
                nodes = call(["docker", "ps", "--all", "--filter", f"name=^/k3d-{name}(-|$)",
                              "--format", "{{.Names}}"], capture_output=True).stdout.strip()
                volumes = call(["docker", "volume", "ls", "--filter", f"name=^k3d-{name}(-|$)",
                                "--format", "{{.Name}}"], capture_output=True).stdout.strip()
                if any(cluster["name"] == name for cluster in remaining) or networks or nodes or volumes:
                    raise ValueError("Owned cluster, node, network or volume survived cleanup")
                result["cleanup"] = "passed"
            except (OSError, ValueError, subprocess.SubprocessError) as error:
                result.update(status="failed", cleanup="failed", cleanup_error=str(error))
Loading