Chapter 28Lesson 02~250 minutes

Kubernetes Agent, Cluster Access, GitOps-Oriented Delivery, Kubernetes Deployments, and Environment Integration: Guided Hands-On Workflow and Core Operations

Build a disposable local cluster workflow with namespace-scoped RBAC, an immutable image reference, rollout evidence, GitLab environment metadata, and exact cleanup—then map the same state to the GitLab Agent.

Hands-onkindRBACNamespaceEnvironment

Learning objectives

  • Create a disposable local Kubernetes target and prove its starting state.
  • Create a namespace-scoped ServiceAccount/Role/RoleBinding and test allowed and denied actions.
  • Resolve a human-readable image tag to a recorded digest and deploy by the digest.
  • Preserve rollout, runtime imageID, manifest and environment-mapping evidence.
  • Map the local workflow to the GitLab Agent without requiring a real Agent, cloud account or paid feature.

1. Scenario and safety boundary

You own a disposable project named glci-ch28-k8s-lab. The local cluster is named glci-ch28; the namespace is glci-ch28; the workload is glci-ch28-web. No production cluster, real cloud identity, shared runner or proprietary image is used.

Safety boundary: every command that creates or deletes Kubernetes state is guarded by the exact lab cluster/namespace names. If your current context is not the disposable cluster, stop. The Agent examples are configuration mappings only unless you own an authorized disposable GitLab Agent.

2. Preflight: record tools, source and current cluster identity

set -eu
printf 'git_sha=%s\n' "$(git rev-parse HEAD)"
kubectl version --client --output=yaml
kind version || true
docker --version || true
kubectl config current-context || true
kubectl config get-contexts -o name || true

In a real GitLab job, add CI_PIPELINE_SOURCE, CI_COMMIT_SHA, CI_PIPELINE_ID and CI_JOB_ID to the evidence packet. This local preflight substitutes the repository HEAD because no GitLab pipeline exists yet.

3. Create or select only the disposable cluster

If you already have a disposable kind/k3d/minikube cluster, use it and record its context. The following path uses kind:

LAB_CLUSTER=glci-ch28
if ! kind get clusters | grep -qx "$LAB_CLUSTER"; then
  kind create cluster --name "$LAB_CLUSTER"
fi
EXPECTED_CONTEXT="kind-$LAB_CLUSTER"
ACTUAL_CONTEXT="$(kubectl config current-context)"
test "$ACTUAL_CONTEXT" = "$EXPECTED_CONTEXT" || {
  echo "Refusing to continue: unexpected context" >&2
  exit 2
}
kubectl cluster-info
kubectl get nodes -o wide

Creating a local cluster changes only local Docker/Kubernetes resources. It does not create a GitLab environment or Agent connection.

4. Bootstrap namespace and a least-privilege identity

Use the administrator context only to create the security boundary. The deployment itself will later use a short-lived ServiceAccount token stored in a temporary kubeconfig file.

apiVersion: v1
kind: Namespace
metadata:
  name: glci-ch28
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: gitlab-ci-sim
  namespace: glci-ch28
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: workload-deployer
  namespace: glci-ch28
rules:
  - apiGroups: ["apps"]
    resources: ["deployments"]
    verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
  - apiGroups: [""]
    resources: ["pods", "services"]
    verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: workload-deployer
  namespace: glci-ch28
subjects:
  - kind: ServiceAccount
    name: gitlab-ci-sim
    namespace: glci-ch28
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: Role
  name: workload-deployer

Save that as lab/rbac.yaml, then:

kubectl apply -f lab/rbac.yaml
kubectl auth can-i create deployments.apps -n glci-ch28   --as=system:serviceaccount:glci-ch28:gitlab-ci-sim
kubectl auth can-i create clusterroles.rbac.authorization.k8s.io   --as=system:serviceaccount:glci-ch28:gitlab-ci-sim
kubectl auth can-i get secrets -n glci-ch28   --as=system:serviceaccount:glci-ch28:gitlab-ci-sim

Expected: deployment creation is yes; cluster-role creation and Secret reads are no. Capture all three answers.

5. Create a short-lived local kubeconfig without displaying its token

This is a faithful identity simulation, not the GitLab Agent itself. The ServiceAccount token is generated through the Kubernetes TokenRequest API, written to a temporary file, embedded into another temporary kubeconfig, and never printed.

mkdir -p .lab-private evidence
chmod 700 .lab-private
TOKEN_FILE=.lab-private/token
KCFG=.lab-private/kubeconfig
kubectl -n glci-ch28 create token gitlab-ci-sim --duration=15m > "$TOKEN_FILE"
chmod 600 "$TOKEN_FILE"
python3 - "$TOKEN_FILE" "$KCFG" <<'PY2'
import json, pathlib, subprocess, sys
source = json.loads(subprocess.check_output([
    'kubectl','config','view','--raw','--minify','-o','json'
], text=True))
cluster = source['clusters'][0]['cluster']
token = pathlib.Path(sys.argv[1]).read_text().strip()
out = {
  'apiVersion': 'v1', 'kind': 'Config',
  'clusters': [{'name':'glci-ch28','cluster':cluster}],
  'users': [{'name':'gitlab-ci-sim','user':{'token':token}}],
  'contexts': [{'name':'glci-ch28-sim','context':{
      'cluster':'glci-ch28','user':'gitlab-ci-sim','namespace':'glci-ch28'}}],
  'current-context':'glci-ch28-sim'
}
pathlib.Path(sys.argv[2]).write_text(json.dumps(out), encoding='utf-8')
PY2
chmod 600 "$KCFG"
KUBECONFIG="$KCFG" kubectl config current-context
KUBECONFIG="$KCFG" kubectl auth can-i create deployments.apps -n glci-ch28
KUBECONFIG="$KCFG" kubectl auth can-i get secrets -n glci-ch28

Do not archive .lab-private. It contains temporary authentication material. The evidence packet records only context name, RBAC results and token expiry intent—not the bearer value.

6. Resolve an upstream tag once, then deploy by digest

For a disposable lab, a versioned tag can be used only as a discovery input. Resolve it, capture its digest, and use the digest thereafter. For stricter reproducibility, start from a previously reviewed digest instead.

SOURCE_IMAGE=nginx:1.27.5-alpine
docker pull "$SOURCE_IMAGE"
IMAGE_REF="$(docker inspect --format='{{index .RepoDigests 0}}' "$SOURCE_IMAGE")"
case "$IMAGE_REF" in
  *@sha256:*) ;;
  *) echo "No immutable RepoDigest resolved" >&2; exit 3 ;;
esac
printf '%s\n' "$IMAGE_REF" > evidence/image-ref.txt
sha256sum evidence/image-ref.txt > evidence/image-ref.txt.sha256

The evidence file is safe: an image digest is identity metadata, not a secret.

7. Generate the exact manifest from the immutable reference

KCFG=.lab-private/kubeconfig
IMAGE_REF="$(cat evidence/image-ref.txt)"
KUBECONFIG="$KCFG" kubectl -n glci-ch28 create deployment glci-ch28-web   --image="$IMAGE_REF" --replicas=1 --dry-run=client -o yaml   > evidence/deployment.yaml
KUBECONFIG="$KCFG" kubectl -n glci-ch28 create service clusterip glci-ch28-web   --tcp=80:80 --dry-run=client -o yaml   > evidence/service.yaml
sha256sum evidence/deployment.yaml evidence/service.yaml   > evidence/manifest-digests.txt

Review the generated YAML before applying it. The manifest hash becomes desired-state evidence.

8. Apply through the narrow identity and preserve the first result

KCFG=.lab-private/kubeconfig
KUBECONFIG="$KCFG" kubectl apply -f evidence/deployment.yaml | tee evidence/apply-deployment.txt
KUBECONFIG="$KCFG" kubectl apply -f evidence/service.yaml | tee evidence/apply-service.txt
KUBECONFIG="$KCFG" kubectl -n glci-ch28 rollout status   deployment/glci-ch28-web --timeout=180s | tee evidence/rollout.txt

The apply output proves API acceptance. rollout status proves the Deployment controller reached its rollout condition. Neither alone proves HTTP behavior.

9. Verify runtime image identity and workload state

KCFG=.lab-private/kubeconfig
KUBECONFIG="$KCFG" kubectl -n glci-ch28 get deployment/glci-ch28-web -o json   > evidence/deployment-after.json
KUBECONFIG="$KCFG" kubectl -n glci-ch28 get pods -l app=glci-ch28-web -o json   > evidence/pods-after.json
KUBECONFIG="$KCFG" kubectl -n glci-ch28 get service/glci-ch28-web -o json   > evidence/service-after.json
python3 - <<'PY2'
import json
pods=json.load(open('evidence/pods-after.json', encoding='utf-8'))
for p in pods['items']:
    for s in p.get('status',{}).get('containerStatuses',[]):
        print(p['metadata']['name'], s.get('ready'), s.get('imageID'))
PY2

Compare each runtime imageID with evidence/image-ref.txt. A digest mismatch is a deployment-integrity problem even if the Pod is Ready.

10. Verify external behavior separately

For the local simulation, start a port-forward in a second terminal:

KUBECONFIG=.lab-private/kubeconfig kubectl -n glci-ch28   port-forward service/glci-ch28-web 18080:80

Then verify from the first terminal:

curl --fail --silent --show-error http://127.0.0.1:18080/   > evidence/http-body.html
wc -c evidence/http-body.html
sha256sum evidence/http-body.html > evidence/http-body.html.sha256

The local URL is a simulation only. Do not register it as a remotely usable GitLab environment URL.

11. Map the lab to a real Agent-enabled GitLab job

If you own an authorized disposable Agent, the equivalent pipeline selects the Agent context explicitly and declares environment metadata:

deploy_staging:
  stage: deploy
  variables:
    KUBE_CONTEXT: platform/cluster-agents:staging-agent
  before_script:
    - kubectl config get-contexts -o name
    - kubectl config use-context "$KUBE_CONTEXT"
    - kubectl auth can-i patch deployments.apps -n app-staging
  script:
    - kubectl apply -f k8s/staging.yaml
    - kubectl rollout status deployment/example -n app-staging --timeout=180s
  environment:
    name: staging
    url: https://staging.example.test
    kubernetes:
      agent: platform/cluster-agents:staging-agent
      dashboard:
        namespace: app-staging

The real job additionally records CI_PIPELINE_SOURCE, CI_COMMIT_SHA, pipeline/job IDs, selected Agent context, manifest digest and rollout evidence. Never upload the kubeconfig as an artifact.

12. Map the same release to GitOps

In a production GitOps design, the pipeline does not need permission to patch the Deployment directly. Instead it updates reviewed desired state—or publishes a versioned OCI manifest artifact—and Flux reconciles that exact revision. The evidence chain becomes source/build digest → desired-state commit/OCI digest → Flux reconciliation → Kubernetes runtime digest → GitLab environment/dashboard/health evidence.

Boundary: the Agent is not the GitOps reconciler. Current GitLab guidance uses Flux for GitOps. Do not restore the removed legacy Agent pull-based GitOps configuration.

13. Challenge: which layer should you change?

The deployment job sees the expected Agent context, but kubectl auth can-i patch deployments -n app-staging returns no. Should you change rules, retry the runner, broaden the project’s Agent authorization, or modify Kubernetes RBAC?

Answer: first preserve the context and denial evidence, then fix the Kubernetes authorization layer if the identity is intended to deploy. Pipeline rules and Agent project authorization already succeeded. Do not grant cluster-admin as a shortcut.

14. Exact cleanup

set -eu
LAB_CLUSTER=glci-ch28
NS=glci-ch28
test "$(kubectl config current-context)" = "kind-$LAB_CLUSTER"
test "$NS" = "glci-ch28"
KUBECONFIG=.lab-private/kubeconfig kubectl -n "$NS" delete   -f evidence/service.yaml -f evidence/deployment.yaml --ignore-not-found
kubectl delete -f lab/rbac.yaml --ignore-not-found
rm -f .lab-private/token .lab-private/kubeconfig
rmdir .lab-private 2>/dev/null || true
kubectl get namespace "$NS" --ignore-not-found
# Optional only when the entire kind cluster was created for this chapter:
# kind delete cluster --name glci-ch28

If lab/rbac.yaml includes the Namespace object, deleting it removes only this guarded disposable namespace. Keep the evidence directory, but never keep temporary authentication files.

Knowledge check

Why does the lab create a temporary ServiceAccount kubeconfig instead of using the administrator context for the deployment?

Why resolve the image tag before generating the manifest?

What evidence differentiates API acceptance from rollout health?

In a real Agent job, what should be recorded instead of the kubeconfig contents?

Why is Flux only mapped conceptually in the mandatory lab?

Next lesson

Next: design choices

Lesson 3 compares push versus GitOps, Agent versus static kubeconfig, namespace isolation, digest promotion and environment integration so the lab patterns scale without broadening trust.

Version and compatibility note

GitLab and GitLab Runner evolve continuously. Treat version-sensitive YAML, runner/executor behavior, APIs, security features, policy controls, tiers, and deprecations as assumptions to verify against the current official GitLab documentation before production use. Preserve the exact project/ref/SHA, compiled configuration, pipeline/job IDs, runner/tool versions, and external target evidence used for any reproducible lab or incident record.

Official references and version notes

Documentation verification date: 2026-09-12. Current GitLab documentation lists the Agent CI/CD workflow as Free, Premium, and Ultimate on GitLab.com, Self-Managed, and Dedicated. Authorized CI/CD jobs receive a KUBECONFIG containing contexts for authorized agent connections; the context identifies the agent configuration project and agent name. ci_access can authorize projects/groups and can restrict access to named environment patterns. The current per-agent authorization limit is 500 projects and 500 groups. CI-job impersonation with access_as: ci_job is Premium/Ultimate; the mandatory lab therefore demonstrates true Kubernetes namespace-scoped ServiceAccount RBAC locally instead of pretending that paid impersonation is Free. GitLab recommends Flux for GitOps; the Agent’s legacy built-in pull-based GitOps functionality was removed in GitLab 17.0. Certificate-based cluster integration was deprecated in GitLab 14.5. The Kubernetes dashboard is currently Beta and available on all tiers. For environment:kubernetes, dashboard namespace/Flux resource settings belong under dashboard:; the older direct namespace/flux_resource_path form is deprecated. The local ServiceAccount token is intentionally short-lived and stored only in a private temporary file. Real Agent jobs should use the Agent-provided kubeconfig and must not replace it with a long-lived production kubeconfig variable.

Current assumptions used in this chapter: Version-sensitive YAML, Runner/executor behavior, APIs, security features, policy controls, tiers, and deprecations must be verified against the exact GitLab, GitLab Runner, tool, and external-system versions used in production. Preserve the exact project/ref/SHA, compiled configuration, pipeline/job IDs, runner/tool versions, and external target evidence used for reproducible work.

Keep the academy open

Support free, practical DevOps education.

Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.