README
¶
Git proxy and kube guard on Kubernetes, end to end
Two tests, on a real cluster:
TestGitProxyAndKubeGuardOnKubernetesruns a workload Pod through both monitors on the hub the Helm chart installed — the rest of this page;TestKubeShareWithAnExecPluginshares the cluster with a hub running on the test host, through a kubeconfig that signs in only with an exec credential plugin (Task 20416) — the last section.
kube_test.go runs a workload Pod through the git interception proxy and the
Kubernetes access monitor on a hub that deploy/helm/cloop-hub installed, and
checks what the Pod, the forge and the audit trail saw (Task 20385). It is the
Kubernetes counterpart of the device kit,
and it runs in CI on the deploy-artifacts job's kind cluster.
It changes the release it is pointed at — both monitors stay on afterwards — so point it only at a throwaway cluster. CI runs it twice: on the release as installed, one replica, and again once the job has scaled it to a hub cluster of three, where a workload's git and kubectl reach members other than the one that minted their sessions and are forwarded.
What it builds
| Piece | What it is |
|---|---|
forge/ |
pkg/secretbroker/secretbrokertest's smart-HTTP forge as a server: the stand-in for github.com, honouring one PAT for every repository it serves, so only the proxy can narrow it |
forge.Dockerfile |
the forge on alpine with git and git-http-backend |
harness.Dockerfile |
the workload image: cloop under test, git, kubectl, and the stand-in claude from scripts/e2e/gitproxy |
probe.sh |
the checks, run inside the Pod; one RESULT line each |
What it does
- Builds both images and loads them into kind.
- Runs the hub's own NetworkPolicy probe (
cloop hub doctor --probe-network-policy) and logs the verdict. Egress blocking is asserted only when the probe proved the CNI enforces policies; otherwise the log says it is not asserted. - Serves the forge in
cloop-e2e-forge-<stamp>with a certificate forgithub.comfrom a throwaway CA.acme/grantedholds one commit and no.cloop/: the project and its task exist only on the hub, so the Pod can only have them from the seed the hub sends in the run's lease Secret (Task 20402). - Creates
cloop-e2e-target-<stamp>andcloop-e2e-second-<stamp>, and a ServiceAccount that may edit both, so a refused write — or a refused read of the other grant's namespace — can only have been refused by the monitor. helm upgrade --reuse-valueswithexecutor.gitProxy,executor.kubeGuard(withauditAllowed),executor.monitorTLS.auto— the hub issues both certificates from its own CA, and nogit_ca_bundleis rendered (checked against the release's config) — the harness image, a per-Pod egress filter opening only the monitors' ports,hostAliasespointing github.com at the forge andextraCACertstrusting its CA.- Registers a project whose origin is
https://github.com/acme/granted.git, then over the API: the project's goal and its one task (POST /api/init,POST /api/tasks); agithub_patsecret granted foracme/grantedonly, writes limited tocloop/e2e-allowed-*; a kubeconfig grant for the target namespace with no verbs (read-only), and the same kubeconfig granted again for the second namespace withcreate— one cluster granted twice, which the Pod receives as one merged kubeconfig with a context per grant (Task 20413); a harness credential; the binding to thekubernetesexecutor. ThenPOST /api/run. - Reads the run's live log and checks:
| id | passes when |
|---|---|
| H1 | the lease's git-credential-cloop is mode 0550 in the Pod |
| T1 | no GitHub token is in the environment, the lease directory or the workspace's git config |
| C1 | the hub's CA came from the run's lease Secret to /etc/cloop/hub-ca/ca.crt and is trusted for the proxy's URL only — the one sslCAInfo in the block, no GIT_SSL_CAINFO — and the kube guard, whose leaf the same CA signed, is refused by git |
| WS1, WS2 | the init container provisioned the workspace through the proxy (the cloop-ws-<handle> Secret's session username at work), and its .git/config holds no credential |
| S1 | the repository tracks no .cloop/, the workspace holds the project the hub sent, and git ignores it, so a write-back cannot commit it |
| P1 | git clone https://github.com/acme/granted works, through the proxy |
| B1 | a push to cloop/e2e-allowed-<stamp> is accepted |
| B2 | a push to cloop/e2e-other-<stamp> is refused by the proxy, with a message naming the grant's branches |
| R1 | a push to acme/other — which the PAT reaches on the forge — is refused by the proxy |
| K0 | the delivered kubeconfig names the monitor, not the API server |
| K1 | kubectl get pods succeeds through the monitor |
| K2 | kubectl create configmap is refused by the monitor as read-only — and, checked from outside, no configmap was created |
| K3 | no cluster token (a JWT) is readable in the Pod: not in the environment, the lease directory or the delivered kubeconfig |
| K4 | kubectl sees one context per kubeconfig grant, current-context the read-only one and first in CLOOP_K8S_CONTEXTS; kubectl --context reaches each grant's own namespace through the monitor and is refused, by the monitor, in the other grant's namespace and in kube-system; and the second grant's context creates a configmap in its namespace — checked from outside too |
| E1 | (only when the CNI enforces NetworkPolicy) the forge is unreachable from the Pod directly |
Then: neither the PAT nor the cluster's ServiceAccount token is anywhere in
the Pod object; the forge holds the allowed
branch at the probe's commit and not the denied one, and acme/other gained
nothing; the audit trail holds gitproxy.push_denied for the denied branch,
gitproxy.push_allowed, a refusal naming acme/other,
kubeguard.request_denied for configmaps and kubeguard.request_allowed for
pods, a kubeguard.request_allowed for the second grant's configmap and a
kubeguard.request_denied for the first grant's context reading the second
namespace; and the run's Pod, Secrets and NetworkPolicy are deleted.
8. Checks the round trip (Task 20402): the hub's own plan — the one the
dashboard renders — shows the task done and attributed to the kubernetes
executor, which can only be true if the run's outcome came back out of the
Pod's log; the live log says the project state came back, and carries none
of the frame it came in. Then it presses Start again and checks the second
run did not execute the task: no probe output in its log, and the task still
done at the same moment.
Running it
On a kind cluster with the chart installed the way CI installs it (the
deploy-artifacts job in .github/workflows/ci.yml):
kind get kubeconfig --name cloop-ci > /tmp/kubeconfig
CLOOP_KUBE_E2E_KUBECONFIG=/tmp/kubeconfig CLOOP_KUBE_E2E_KIND_CLUSTER=cloop-ci \
go test ./tests/kube/ -run TestGitProxyAndKubeGuardOnKubernetes -count=1 -v -timeout 28m
| variable | meaning |
|---|---|
CLOOP_KUBE_E2E_KUBECONFIG |
required; without it the test skips |
CLOOP_KUBE_E2E_KIND_CLUSTER |
the kind cluster to load the images into |
CLOOP_KUBE_E2E_IMAGE_IMPORT |
for any other cluster: a shell command that reads a docker save tarball on stdin and loads it onto the nodes, e.g. docker exec -i k3s ctr images import -. With neither, the images must already be there |
CLOOP_KUBE_E2E_CLOOP_BIN |
a static cloop for the harness image; unset, one is built (CI copies the hub image's) |
CLOOP_KUBE_E2E_RELEASE, CLOOP_KUBE_E2E_NAMESPACE |
the release, cloop in cloop by default |
CLOOP_KUBE_E2E_KEEP |
leave the forge, the target namespace and the helper Pod behind |
A kind node image is about 1 GB on disk and the test images another few hundred MB, loaded twice; check free space before running it on a small host.
Sharing a cluster whose kubeconfig runs a plugin
share_test.go needs only the cluster, not the chart: the hub
runs on the test host (cloop ui with executors.kube_guard on, tls: auto,
the host executor and the stand-in claude), as it would on a developer's
machine. It:
- creates four namespaces — two to share, one for the ServiceAccount, one for a
"developer" ServiceAccount holding
adminin the other three — and a kubeconfig whose only credential is a fake exec plugin printing the developer's token (client.authentication.k8s.io/v1, a relative command path,provideClusterInfo); - runs
cloop remote kube share --access write --sa-namespace … --ttl 10mand checks the plugin was told the cluster, the five objects and their RBAC, and that RBAC alone would let the ServiceAccount create its own token; - grants the share to the hub's project for writes and dispatches a task
running
share_probe.sh— which, besides reading and writing, tries every way a write grant could mint a ServiceAccount credential of its own (Task 20420); the test then checks from outside that none of those objects exists, and that a Deployment the cluster administrator made, running as a ServiceAccount of its own, still has its image; - waits for the hub to renew the share, then for the first token to expire, and has the same run use the cluster again;
- revokes the share and checks nothing labelled
cloop.dev/kube-share=<id>is left, the secret is gone and the run is cut off; - uploads a crafted share: its token is for a ServiceAccount the cluster
administrator made, an object named like the share's and a decoy labelled
with its id by another owner sit in its namespace, and its metadata lists
cluster-admin's binding. Revoked with the administrator's own kubeconfig, it deletes nothing and keeps the hub's secret;--forcedeletes the secret and still nothing in the cluster. Then the test reads the audit trail, including akubeguard.request_deniedrow for each credential refusal's reason.
| id | passes when |
|---|---|
| P0 | the run's kubeconfig names the monitor and carries a session token — no JWT there or in the environment |
| P1, P2 | kubectl get and kubectl create configmap work through the monitor |
| P3 | kubectl create token <the share's ServiceAccount> is refused by the monitor, under a write grant whose namespaces include the ServiceAccount's |
| P4 | a raw POST …/serviceaccounts/default/token is refused by the monitor |
| P5 | kube-system is refused by the monitor |
| P6 | after the token the run's session started with has expired, the run reads and writes again (the hub renewed the share; the session took the new token up) |
| P7 | after cloop remote kube revoke, the run can no longer reach the cluster |
| P8 | a kubernetes.io/service-account-token Secret is refused by the monitor (service_account_token_secret) |
| P9, P10 | a Pod, and a Deployment, naming a ServiceAccount other than the namespace's default are refused (workload_service_account) |
| P11 | a Pod mounting the node's /var/lib/kubelet through hostPath is refused (host_access) |
| P12 | kubectl set image on a Deployment that already runs as another ServiceAccount is refused — the monitor reads the Deployment before the patch |
| P13 | a RoleBinding is refused (rbac_binding) |
| P14 | an ordinary Pod, as the namespace's default ServiceAccount, passes (a server-side dry run, so nothing is scheduled) |
kind create cluster --name cloop-share
kind get kubeconfig --name cloop-share > /tmp/kubeconfig
CLOOP_KUBE_E2E_KUBECONFIG=/tmp/kubeconfig \
go test ./tests/kube/ -run TestKubeShareWithAnExecPlugin -count=1 -v -timeout 25m
About thirteen minutes, nearly all of it waiting: ten minutes is the shortest
token the TokenRequest API issues, and the API server accepts one for a minute
past its expiry. CLOOP_KUBE_E2E_CLOOP_BIN supplies the binary; unset, one is
built. CI runs it in the kube-share job on a kind cluster of its own.
Directories
¶
| Path | Synopsis |
|---|---|
|
Command forge serves pkg/secretbroker/secretbrokertest's git forge over TLS, for the Kubernetes end-to-end test (tests/kube, Task 20385).
|
Command forge serves pkg/secretbroker/secretbrokertest's git forge over TLS, for the Kubernetes end-to-end test (tests/kube, Task 20385). |