Loading .gitlab-ci.yml +4 −1 Changes for .gitlab-ci.yml: 4 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -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: Loading ansible/tests/unit/test_platform_lab.py +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 Loading @@ -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) Loading Loading @@ -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" ci/lab/README.md +20 −2 Changes for ci/lab/README.md: 20 added lines, 2 removed lines. Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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. Loading @@ -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. Loading ci/lab/profile.health.json 0 → 100644 +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": [] } ci/platform_lab.py +38 −5 Changes for ci/platform_lab.py: 38 added lines, 5 removed lines. Original line number Diff line number Diff line Loading @@ -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): Loading Loading @@ -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]) Loading Loading @@ -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 Loading
.gitlab-ci.yml +4 −1 Changes for .gitlab-ci.yml: 4 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -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: Loading
ansible/tests/unit/test_platform_lab.py +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 Loading @@ -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) Loading Loading @@ -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"
ci/lab/README.md +20 −2 Changes for ci/lab/README.md: 20 added lines, 2 removed lines. Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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. Loading @@ -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. Loading
ci/lab/profile.health.json 0 → 100644 +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": [] }
ci/platform_lab.py +38 −5 Changes for ci/platform_lab.py: 38 added lines, 5 removed lines. Original line number Diff line number Diff line Loading @@ -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): Loading Loading @@ -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]) Loading Loading @@ -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