Repository navigation
feat(deploy/k8s): ocp-single — single-namespace, no-SCC dev/test target - #443
Conversation
…target Add an optional install mode for shared OpenShift clusters where the operator holds RBAC on exactly one namespace and cannot be granted SCC or cluster-scoped access (issue rossoctl#426). The checked-in overlay deploy/k8s/overlays/ocp-single collapses the three-namespace P6 topology into one namespace (moca-single by default, SH_SINGLE_NAMESPACE to rename): - strip runAsUser/runAsGroup/fsGroup from all five workloads so the restricted-v2 SCC assigns UID/GID from the namespace range (no SCC grant needed); runAsNonRoot and seccompProfile are kept - mount emptyDir volumes over /workspace and /home/sandbox so the image-baked dirs (uid 1001, mode 775) are writable under the assigned UID; writable-layer data is now pod-local and lost on pod restart - rewrite cross-namespace addressing into in-namespace service DNS (relay, control plane, sandbox, credentials) - replace namespaceSelector network policies with pod-label policies inside the single namespace, and point DNS egress at openshift-dns (port 5353) instead of kube-system/kube-dns (port 53) - drop the three Namespace objects and duplicated network policies with $patch: delete - keep the sandbox pool self-contained: the moca-sandbox StatefulSet, its Role/RoleBinding, and the relay attach Secret are all rendered inside the one namespace; P4 hosts cannot attach in this mode (no Route is rendered) setup.sh gains --target ocp-single: the namespace must already exist (created by the cluster's admin); SH_SINGLE_NAMESPACE is not sticky. smoke.sh probes the in-namespace services and downgrades the kube-API egress claim to a note (cluster-level admission decides on a shared cluster — documented in README §12.3). Validation: - kind cluster with a simulated assigned UID 1000670000: smoke 11/11 - real shared OCP (ROKS) tenant under restricted-v2 with assigned uid 1002610000: smoke 11/11 with a mock model (claims 3/4/8/9 need a real model credential; the remaining claims passed on the final PR branch run) - deploy/k8s/tests/setup.test.sh and smoke.test.sh: all passed - packages/supervisor vitest (k8s render suite): 66/66 Closes rossoctl#426 (dev/test single-namespace option) Co-Authored-By: Claude Code <noreply@anthropic.com> Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…ingle test
- prettier reformat: README §12, patch-addressing.yaml, render.ts Target
union, overlay-ocp-single.test.ts
- overlay-ocp-single.test.ts: cast envVar's valueFrom (typed unknown) to
{ fieldRef } / { secretKeyRef } before asserting SANDBOX_ID and
SANDBOX_TOKEN, fixing the two TS2571 typecheck errors
Co-Authored-By: Claude Code <noreply@anthropic.com>
Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
pdettori
left a comment
There was a problem hiding this comment.
Summary
A well-scoped ocp-single target. The overlay design checks out: the base has exactly two cross-namespace namespaceSelector policies, and patch-policies.yaml rewrites both. Every workload except the control plane keeps automountServiceAccountToken: false. Credential Secret names are hashed (sh-cred-<hash>), so they can't collide with platform Secrets. CI isn't green, and the PR has one unrelated regression in setup.sh's preflight.
lintfails on prettier forpackages/supervisor/test/k8s/render.ts,overlay-ocp-single.test.ts,deploy/k8s/overlays/ocp-single/patch-addressing.yamlanddeploy/k8s/README.md. Runpnpm exec prettier --writeon those four.- The PR body says both "Refs #426" and "Closes #426", and also mentions later #426 slices. Merging will auto-close the tracking issue; switch to
Refsif that isn't intended.
Author: pdettori (MEMBER — maintainer)
Areas reviewed: Shell (setup/smoke/tests), Kustomize/K8s YAML, NetworkPolicy/RBAC isolation, TypeScript tests, Docs
Agent/IDE config (.claude/.vscode): none
Commits: 1 commit, all signed-off: yes
CI status: failing (check: TS2571 typecheck; lint: prettier)
Verdict: changes requested (inline must-fix #1 and #2, plus lint). Submitted as COMMENT because this is a self-review.
Assisted-By: Claude Code
| expect(ss.spec.replicas).toBe(2); | ||
| expect(ss.spec.serviceName).toBe('moca-sandbox'); | ||
| // SANDBOX_ID from the pod name, so the pool IDs stay unique and stable. | ||
| expect(envVar(container(ss, 'sandbox'), 'SANDBOX_ID')?.valueFrom.fieldRef.fieldPath).toBe( |
There was a problem hiding this comment.
must-fix — check fails on TS2571: Object is of type 'unknown' here and at L106. envVar() (render.ts) types valueFrom as unknown, so you can't chain .fieldRef/.secretKeyRef off it. Vitest passes because it doesn't type-check; tsc --noEmit does. Suggested:
expect(envVar(container(ss, 'sandbox'), 'SANDBOX_ID')).toMatchObject({
valueFrom: { fieldRef: { fieldPath: 'metadata.name' } },
});
expect(envVar(container(ss, 'sandbox'), 'SANDBOX_TOKEN')).toMatchObject({
valueFrom: { secretKeyRef: { name: 'moca-relay-attach', key: 'SH_RELAY_TOKEN' } },
});| # ocp-single runs kubectl only: no SCC grant and no Route reads, so oc is never needed. | ||
| for c in $need; do command -v "$c" >/dev/null 2>&1 || missing="$missing $c"; done | ||
| command -v sha256sum >/dev/null 2>&1 || command -v shasum >/dev/null 2>&1 || missing="$missing sha256sum|shasum" | ||
| [[ -z "$missing" ]] || die "missing required commands:$missing" |
There was a problem hiding this comment.
must-fix — This hunk deletes command -v sha256sum ... || command -v shasum ... || missing="$missing sha256sum|shasum" from preflight for every target, and nothing in the ocp-single scope needs that. sha256() (L494) still needs one of the two. Without the check, a host that has neither fails at write_settings under pipefail, after the Secrets are already applied, with no clear message. It looks accidental; please restore the line.
There was a problem hiding this comment.
Restored in e338f59. It was accidental, and the check runs for every target again.
| # possible, so a smoke against a custom namespace needs no extra flag; the default covers a | ||
| # ConfigMap that cannot be read. | ||
| if [[ "$TARGET" == ocp-single ]]; then | ||
| NS="$(kc get configmap moca-setup -o jsonpath='{.data.SH_SINGLE_NAMESPACE}' 2>/dev/null || true)" |
There was a problem hiding this comment.
suggestion — kc get configmap moca-setup has no -n, so it reads from the kubeconfig's current namespace. You need the namespace to find the ConfigMap that is supposed to tell you the namespace. If the context isn't set to the tenant namespace, smoke quietly falls back to moca-single; with two stacks (README §12.4 allows that) it can smoke the wrong one. Suggest honoring SH_SINGLE_NAMESPACE first:
NS="${SH_SINGLE_NAMESPACE:-$(kc get configmap moca-setup -o jsonpath='{.data.SH_SINGLE_NAMESPACE}' 2>/dev/null || true)}"There was a problem hiding this comment.
Done in e338f59: an exported SH_SINGLE_NAMESPACE now wins, and the ConfigMap read is only the fallback. The comment says the fallback reads the kubeconfig's current namespace.
| There is no in-pod mock model on this target (the `kind-ci` one rides the dev.local image), so the | ||
| smoke needs a real model credential, like `--target ocp`. The claims are the container tier's: | ||
| pods up, sandboxes attached, authenticated turns in a sandbox, isolation (redis, relay exec, kube | ||
| API, metadata BLOCKED; relay attach OPEN), drain, Redis-restart persistence, no restarts. The |
There was a problem hiding this comment.
suggestion — This claim list says "kube API … BLOCKED", but for ocp-single smoke.sh turns that claim into a note. The next line ("a custom SH_SINGLE_NAMESPACE needs no extra flag") only holds when the kubeconfig context is already in that namespace (see the smoke.sh comment).
There was a problem hiding this comment.
Fixed in e338f59. §12.2 now lists kube API as a note, not a claim. It also says to export the install-time SH_SINGLE_NAMESPACE for a custom namespace, because the ConfigMap fallback only works when the context is already in it.
| exec-port rule (supervisor only) are all enforced per pod label. The sandboxes mount no ServiceAccount | ||
| token, so the kube API refuses them even where a cluster-level policy leaves it reachable (§12.3). | ||
|
|
||
| Two consequences of the collapse, accepted for a dev/test tenant: the secrets the base keeps out of |
There was a problem hiding this comment.
suggestion — One consequence of the collapse isn't listed here. In the base, moca-control-plane-credentials grants get/create/update/patch/delete on Secrets only in moca-credentials. Collapsed into one namespace, the Role covers every Secret: relay attach/exec tokens, the Redis password, the MU1 keys. A compromised control plane can now read and overwrite platform secrets, not just user credentials. That may be acceptable for dev/test, but this paragraph should say so.
There was a problem hiding this comment.
Added to §12.1 in e338f59 as a third consequence. The Role has no resourceNames, so once the namespaces collapse it reaches the relay tokens, the Redis password and the MU1 keys. Those names are fixed, so "no list" doesn't help.
|
|
||
| **The kube-API Service VIP was reachable** from the sandbox despite the private-range egress | ||
| blocks, on that cluster only: same-CIDR VIPs (DNS) were blocked, arbitrary private IPs were | ||
| blocked, and other ports on the VIP were blocked -- a cluster-level admission that predates this |
There was a problem hiding this comment.
suggestion — "Cluster-level admission" probably isn't the mechanism; admission doesn't filter traffic. What you observed fits OVN-K evaluating ipBlock after the Service VIP is DNAT'd: the DNS VIP is blocked (backends are pod IPs in excepted ranges), other VIP ports are blocked (no endpoint), and the API port is open because the apiserver endpoint sits outside the except list (e.g. a ROKS master endpoint), so the overlay's own 0.0.0.0/0 rule admits it. If so, the cause is the sandbox internet-egress rule, not the cluster. Worth rewording here and in smoke.sh's note text.
There was a problem hiding this comment.
Reworded in e338f59, in README §12.3 and in smoke.sh's comment and note. The text now names the sandbox's own 0.0.0.0/0 rule matching after DNAT as the most likely cause, and drops "admission". I kept it hedged: I couldn't confirm the apiserver endpoint on that tenant. On ROKS it is often 172.20.0.1:2040, which is inside 172.16.0.0/12, and that would contradict this explanation. The README says the endpoint is unconfirmed. Worth checking kubectl get endpointslices -n default -l kubernetes.io/service-name=kubernetes next time we have the tenant.
There was a problem hiding this comment.
Correction to my reply above. I re-probed the ROKS tenant from moca-sandbox-0, and the theory doesn't hold. f6d4afb rewrites §12.3 and the smoke note to match:
- The CNI is Calico, not OVN-K.
kubernetes.default.svc→172.21.0.1:443→ ROKS node-local proxy172.20.0.1:2040. A direct connection to172.20.0.1:2040is OPEN, even though it's inside the excepted172.16.0.0/12. Other ports on that IP,10.0.0.1and the DNS VIP are blocked. So the0.0.0.0/0rule isn't what admits it. Some cluster-level allowance is, and I can't read it with namespace-only rights (GlobalNetworkPolicies are forbidden). The original "cluster-level" wording was closer; only "admission" was wrong.
Two more findings, now documented in §12.3:
- The node network is on a non-RFC 1918 range, which is outside the
exceptlist. The sandbox reaches its node's kubelet (10250) and SSH (22). - A preinstalled tenant
allow-same-namespacepolicy widens ingress to every pod. Isolation rests on the sandbox's egress policy, which holds: Redis and relay exec are BLOCKED.
| # (readOnlyRootFilesystem, drop ALL, no privilege escalation) already satisfy restricted-v2. | ||
| # | ||
| # What must survive without an explicit UID, and does (verified on a shared OCP 4.x tenant, | ||
| # README §12.4): the credential and token Secret mounts (SCC admission chowns secret volumes to |
There was a problem hiding this comment.
nit — This cites "README §12.4" for the verification. That's §12.3; §12.4 is "What does not work here".
| reset_state | ||
| (export SH_GITHUB_CLIENT_ID=Iv1.a; expect_ok --target ocp-single) | ||
| expect_out 'kubectl -n moca-single port-forward svc/moca-supervisor' | ||
| ! grep -q 'ocp-single.*https' "$TMP/out" || fail 'ocp-single printed an https URL; it has no Routes' |
There was a problem hiding this comment.
nit — This assertion can never fail: the ocp-single access banner doesn't contain the string ocp-single, so 'ocp-single.*https' never matches. Grep the access output for https:// instead.
There was a problem hiding this comment.
Fixed in e338f59: the assertion now greps for https://. Mutation-checked: an https:// line injected into the ocp-single banner makes the test fail; it passes without it.
…e, isolation docs (rossoctl#443 review) - setup.sh: restore the sha256sum|shasum preflight check, dropped by accident; sha256() still needs one of the two on every target. - smoke.sh: an exported SH_SINGLE_NAMESPACE wins over the ConfigMap read, which only sees the kubeconfig's current namespace. - smoke.sh / README §12.3: the reachable kube-API VIP is most likely the sandbox's own 0.0.0.0/0 egress rule matching after DNAT, not cluster admission. - README §12.1: the control plane's Secret Role covers every Secret once the namespaces collapse; §12.2: kube API is a note, not a claim. - patch-strip-uids.yaml: cite §12.3, not §12.4. - setup.test.sh: the no-https assertion could never fail; grep for https://. - overlay-ocp-single.test.ts: toMatchObject instead of casts. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…ally shows (rossoctl#443 review) Re-probed the shared tenant from a sandbox pod: - The CNI is Calico (IBM Cloud ROKS), not OVN-Kubernetes. - kubernetes.default.svc (172.21.0.1:443) DNATs to the node-local apiserver proxy 172.20.0.1:2040, which the sandbox also reaches directly even though it is inside the excepted 172.16.0.0/12. So the 0.0.0.0/0 rule is not what admits it; a cluster-level allowance is. smoke.sh's note says so again, without the word "admission". - The node network (9.47.x.x) is not a private range: the sandbox reaches its node's kubelet (10250) and SSH (22). Documented, with where to widen the except list. - A preinstalled tenant allow-same-namespace policy widens ingress; the sandbox's egress policy is what keeps Redis and the relay exec port BLOCKED. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
…ally shows (rossoctl#443 review) Re-probed the shared tenant from a sandbox pod: - The CNI is Calico (IBM Cloud ROKS), not OVN-Kubernetes. - kubernetes.default.svc (172.21.0.1:443) DNATs to the node-local apiserver proxy 172.20.0.1:2040, which the sandbox also reaches directly even though it is inside the excepted 172.16.0.0/12. So the 0.0.0.0/0 rule is not what admits it; a cluster-level allowance is. smoke.sh's note says so again, without the word "admission". - The node network is on a non-RFC 1918 range: the sandbox reaches its node's kubelet (10250) and SSH (22). Documented, with where to widen the except list. - A preinstalled tenant allow-same-namespace policy widens ingress; the sandbox's egress policy is what keeps Redis and the relay exec port BLOCKED. Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
f6d4afb to
4e34b9d
Compare
… egress except list (#446) The sandbox's internet egress rule allows 0.0.0.0/0 on every port except RFC 1918, CGNAT and link-local. On clusters whose node or infrastructure network is publicly routable (seen on IBM Cloud ROKS during #443), that rule admits the node's kubelet and SSH. SH_SANDBOX_EGRESS_EXCEPT takes comma-separated IPv4 CIDRs and appends them to the except list on every target, through the generated overlay. A JSON-patch test op guards the rule index. Entries are validated before anything touches the cluster: canonical CIDR, prefix 1-32, not already a built-in range, no duplicates. Empty by default, so behaviour is unchanged. Sticky in moca-setup like the other inputs; set-but-empty clears it. Fixes #446 Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: Paolo Dettori <dettori@us.ibm.com>
What
Adds an optional dev/test install mode (
setup.sh --target ocp-single, smoke targetocp-single) for shared OpenShift clusters where the operator holds RBAC on exactly one namespace and cannot be granted SCC or cluster-scoped access. Refs #426.The default install is unchanged: three namespaces +
nonroot-v2SCC (--target ocp). The new mode is dev/test only and self-contained — the sandbox pool (moca-sandboxStatefulSet, its Role/RoleBinding, the relay attach Secret) is included in the single namespace.How the three-namespace topology collapses
New overlay
deploy/k8s/overlays/ocp-single(namespacemoca-singleby default,SH_SINGLE_NAMESPACEto rename):runAsUser/runAsGroup/fsGroupfrom all five workloads so the namespace'srestricted-v2SCC assigns UID/GID from the range;runAsNonRoot+seccompProfilekept./workspaceand/home/sandbox(image-baked dirs are uid 1001, mode 775). Trade-off: writable-layer data is pod-local, lost on restart — documented in README §12.1.openshift-dnson 5353 (vs kube-system/kube-dns on 53).Namespaceobjects and duplicated policies are removed via$patch: delete.Base and #426 interaction
ccc17b0(post-feat: sandbox tiers and session-to-sandbox affinity — harness, relay, workers (P6.3 PR 1, #425) #442);deploy/k8swas unchanged between the feat: P6 on Kubernetes, slice 2 — external P4 hosts over TLS (#424) #437 merge and this PR, so there is no overlay drift.moca.dev/tier=container,SH_SANDBOX_TIERSunset = no filtering) compose with this mode — verified, no conflict. The overlay relies on slice-3 labels for the sandbox pool's pod selectors.Validation
restricted-v2with assigned uid 1002610000: smoke 11/11 with a mock model (experiment branch). On the final PR-branch run against that tenant, all infrastructure claims pass (1, 2, 5, 6, 7, 10, 11); claims 3/4/8/9 fail only for lack of a real model credential — expected per README §12.2.kubernetes.default.svc:443is reachable from the sandbox on the shared tenant despite the overlay's ipBlock exceptions: the VIP DNATs to ROKS's node-local proxy172.20.0.1:2040, which the sandbox reaches directly even though it is inside the excepted172.16.0.0/12, so a cluster-level allowance (Calico; not readable with namespace rights) admits it, not the overlay. The sandbox holds no ServiceAccount token, so the API refuses it. The same re-probe found the tenant's node network (a non-RFC 1918 range) outside theexceptlist (the sandbox reaches the node's kubelet and SSH), documented in README §12.3. Smoke downgrades this claim to anoteforocp-single; defence-in-depth trade-off documented in README §12.3.deploy/k8s/tests/setup.test.sh: all passed (5 new ocp-single blocks).deploy/k8s/tests/smoke.test.sh: all passed.packages/supervisorvitest k8s render suite: 66/66 (newoverlay-ocp-single.test.ts, 6 tests: single-namespace collapse, UID stripping, emptyDirs, env rewrite, policy edges, sandbox-pool self-containment).Docs
README §12 covers the target: §12.1 namespace-scoped comparison and isolation trade-offs, §12.2 smoke with a real model, §12.3 the verified kube-API VIP finding, §12.4 P4 non-attach and
SH_SINGLE_NAMESPACEnon-stickiness.Refs #426 (not closed by this PR; later slices remain)
🤖 Generated with Claude Code