kubectl block

14 min read

The kubectl: block provides a structured alternative to commands: for the most common assertion patterns. Each subcommand maps directly to the kubectl command people already know — kubectl.get, kubectl.logs, kubectl.describe, kubectl.exec, kubectl.port-forward.

Raw commands: stays for anything that doesn’t fit a subcommand — though kubectl.apply, kubectl.delete, and kubectl.patch now cover the most common mutation patterns.


Structure

kubectl: sits alongside resources: and commands: in each expect: entry:

expect:
  - name: Deployment has correct resource profile
    after: cr-applied
    timeout: 60s
    resources:
      - kind: Deployment
        name: my-service
        namespace: default
    kubectl:
      get:
        - kind: Deployment
          name: my-service
          field: .spec.template.spec.containers[0].resources.requests.cpu
          equals: 200m

All subcommands in a kubectl: block are checked in the same polling loop as resources: and commands:. All must pass for the checkpoint to pass.

Execution order

Within a single kubectl: block, subcommands always execute in this fixed order regardless of how they appear in the YAML:

  1. apply — create or update resources
  2. patch — modify fields on existing resources
  3. restart — trigger a rollout restart
  4. scale — change replica count
  5. delete — remove resources
  6. get, logs, describe, exec, port-forward, events, auth, cp, top — assertions (read-only)

Mutations always run before assertions so that state changes take effect before they are evaluated. Within mutations, the order is create → modify → destroy — so you can scale a resource in the same block that later deletes it.


Assertion fields

Every subcommand supports the same assertion fields:

FieldDescription
equalsOutput (trimmed) must exactly match this string
notEqualsOutput must not exactly match this string
oneOfOutput (trimmed) must match one of the listed strings
notOneOfOutput (trimmed) must not match any of the listed strings
outputContainsOutput must contain this substring
outputNotContainsOutput must not contain this substring
regexOutput (trimmed) must match this RE2 regular expression (Go’s regexp syntax)
greaterThanOutput (trimmed, parsed as a number) must be greater than this value — strict
lessThanOutput (trimmed, parsed as a number) must be less than this value — strict
greaterThanOrEqualOutput must be greater than or equal to this value
lessThanOrEqualOutput must be less than or equal to this value
betweenOutput must be numerically within an inclusive range. Value is "min,max"
notBetweenOutput must be numerically outside an inclusive range. Value is "min,max"
existsOutput (trimmed) must be non-empty — field is present and has a value
notExistsOutput (trimmed) must be empty — field is absent or unset

Multiple assertions on the same entry all apply. Empty fields are ignored. These are evaluated with the same Condition operators as when:/anyOf: (see when/anyOf conditions & Operators), against a single synthetic output field holding the trimmed command output — the numeric comparisons fail if the output is not parseable as a number.

oneOf is useful when the expected value is one of several valid strings — for example, a status field that reflects current runtime state:

kubectl:
  get:
    - kind: APIServer
      name: my-api
      namespace: default
      field: .status.phase
      oneOf: [Peak, Steady]

kubectl.get

Generates: kubectl get <kind> <name> -n <namespace> -o jsonpath='{<field>}'

kubectl:
  get:
    # jsonpath field extraction
    - kind: Deployment
      name: my-service
      namespace: default
      field: .spec.template.spec.containers[0].resources.requests.cpu
      equals: 200m

    # full JSON output with jq extraction
    - kind: ResourceQuota
      name: my-service-quota
      namespace: default
      format: json
      jq: .status.hard.pods
      equals: "10"

    # full YAML output with yq extraction
    - kind: ConfigMap
      name: my-config
      namespace: default
      format: yaml
      yq: .data.maxConnections
      outputContains: "100"
FieldRequiredDescription
kindyesKubernetes resource kind
nameyesResource name
namespacenoNamespace. Default: default
fieldnojsonpath expression to extract. e.g. .spec.replicas
formatnojson or yaml — returns the full resource. Ignored when field is set
jqnojq expression applied to output before asserting. Requires format: json
yqnoyq expression applied to output before asserting. Requires format: yaml

kubectl.logs

Generates: kubectl logs -n <ns> [-l <selector> | <name>] [-c <container>] [--since=<since>]

kubectl:
  logs:
    # assert a log line exists
    - labelSelector: app=my-service
      namespace: default
      since: 30s
      outputContains: "server started on port 8080"

    # assert no error logs (JSON structured logging)
    - labelSelector: app=my-service
      namespace: default
      jq: .level
      outputNotContains: error

    # assert exact log message in a named pod
    - name: my-service-abc123
      container: sidecar
      outputContains: "config reloaded"

    # assert a log line emitted by the current leader pod
    - leaderElection:
        lease: my-operator-leader
        namespace: my-operator-system
      outputContains: "acquired leader lock"
FieldRequiredDescription
namenoPod name. Mutually exclusive with leaderElection
labelSelectornoLabel selector (e.g. app=my-service). Mutually exclusive with leaderElection
leaderElectionnoResolve the log target from a Kubernetes Lease holder. Mutually exclusive with name and labelSelector. See leaderElection
namespacenoNamespace. Default: default
containernoContainer name. Defaults to the first container
sincenoLimit output to logs from the last duration (e.g. 30s, 2m)
jqnojq expression applied to each log line. Useful for JSON-structured logs

Notename, labelSelector, and leaderElection are mutually exclusive. Exactly one must be provided.


kubectl.describe

Generates: kubectl describe <kind> [-n <ns>] [<name> | -l <selector>]

Useful for asserting Kubernetes events, conditions, and resource details that don’t appear in structured fields.

kubectl:
  describe:
    # assert image was pulled successfully
    - kind: Pod
      labelSelector: app=my-service
      namespace: default
      outputContains: "Successfully pulled image"

    # assert no crash events
    - kind: Pod
      labelSelector: app=my-service
      namespace: default
      outputNotContains: "Back-off restarting failed container"
FieldRequiredDescription
kindyesKubernetes resource kind
namenoResource name. Use labelSelector to match by label instead
labelSelectornoLabel selector
namespacenoNamespace. Default: default

kubectl.exec

Generates: kubectl exec -n <ns> <pod> [-c <container>] -- <command>

kubectl:
  exec:
    # verify a config file was mounted correctly
    - labelSelector: app=my-service
      namespace: default
      command: [cat, /etc/config/app.conf]
      outputContains: "maxConnections=100"

    # verify a secret is accessible inside the container
    - labelSelector: app=my-service
      namespace: default
      container: app
      command: [sh, -c, "echo $DB_PASSWORD"]
      outputNotContains: ""
FieldRequiredDescription
namenoPod name. Use labelSelector to match by label instead
labelSelectornoLabel selector. One of name or labelSelector required
namespacenoNamespace. Default: default
containernoContainer name. Defaults to the first container
commandyesCommand to run as a list (no shell interpolation)
jqnojq expression applied to the output before asserting
yqnoyq expression applied to the output before asserting

kubectl.port-forward

Opens a port-forward to a service, pod, or the elected leader of a Kubernetes Lease, makes an HTTP request via curl, and asserts the response. The runner manages the port-forward lifecycle — background start, port-open polling, curl, cleanup. No shell scripting required.

curl, jq, and yq are installed automatically if not present when detected in the spec.

kubectl:
  port-forward:
    # assert via service
    - service: my-api
      namespace: default
      port: 9090
      path: /healthz
      outputContains: "ok"

    # assert a YAML API endpoint
    - service: my-api
      namespace: default
      port: 9090
      path: /config
      method: GET
      yq: .maxConnections
      outputContains: "100"

    # assert via leader election — port-forward directly to the leader pod
    - namespace: my-operator-system
      port: 8080
      path: /healthz
      leaderElection:
        lease: my-operator-leader
      outputContains: "ok"

    # authenticated POST — e.g. a gateway Gateway API behind a bearer token
    - service: orkestra-gateway
      namespace: orkestra-system
      port: 8443
      path: /api/v1/apply
      method: POST
      headers:
        Authorization: "Bearer ${ORK_CI_TOKEN}"
        Content-Type: application/json
      body: '{"apiVersion":"platform.myorg.io/v1","kind":"AppRequest","metadata":{"name":"bad"},"spec":{"replicas":1}}'
      outputContains: '"accepted":false'
FieldRequiredDescription
servicenoService name to port-forward to. Required when leaderElection is not set and pod is not set
podnoPod name to port-forward to. Alternative to service
leaderElectionnoResolve the port-forward target from a Kubernetes Lease. When set, service and pod are not required. See leaderElection
namespacenoNamespace. Default: default
portyesPort to forward (used as both local and remote)
pathnoHTTP path to request via curl after port-forward is ready
methodnoHTTP method. Default: GET
headersnoMap of request headers, e.g. Authorization for a token-gated endpoint. Values go through os.ExpandEnv (${VAR} syntax, same as gateway.api.auth.tokens.token) so a CI secret never needs to be written into the e2e file
bodynoRequest body for POST/PUT/PATCH. Also goes through os.ExpandEnv
waitnoDuration to sleep after the port-forward is ready but before sending the curl request (Go duration: 5s, 10s). Useful when the endpoint needs time to stabilize
jqnojq expression applied to the response before asserting
yqnoyq expression applied to the response before asserting

leaderElection

Resolves the port-forward target by reading a Kubernetes Lease object and port-forwarding directly to the holder pod. This guarantees that assertions run against the process with authoritative state — not a follower that may return stale data.

kubectl:
  port-forward:
    - namespace: my-operator-system
      port: 8080
      path: /metrics
      leaderElection:
        lease: my-operator-leader
        namespace: my-operator-system   # optional; defaults to the port-forward namespace
      outputContains: "process_start_time"
FieldRequiredDescription
leaseyesName of the coordination.k8s.io/v1 Lease object
namespacenoNamespace of the Lease. Defaults to the port-forward namespace

At runtime, the harness runs kubectl get lease <name> -n <namespace> -o jsonpath='{.spec.holderIdentity}' to find the current holder, then opens a port-forward to pod/<holder>. If the Lease has no holder yet, the step retries until the checkpoint times out.

See Testing Leader-Led Deployments for the full picture on why this matters and when to use it.


kubectl.apply

Applies manifests during an expect checkpoint. Use file to reference a path on disk or inline to embed the manifest directly. kubectl apply is idempotent so re-running inside the poll loop is safe.

Generates: kubectl apply -f <file> or echo '<inline>' | kubectl apply -f -

kubectl:
  apply:
    # apply a file relative to the e2e.yaml directory
    - file: ./fixtures/v2-cr.yaml

    # apply an inline manifest
    - inline: |
        apiVersion: v1
        kind: ConfigMap
        metadata:
          name: feature-flags
          namespace: default
        data:
          v2: enabled

    # apply with a namespace override
    - file: ./fixtures/tenant-quota.yaml
      namespace: team-alpha

    # assert a rejection — e.g. an admission webhook denial — instead of
    # treating any apply failure as a broken test
    - file: ./fixtures/duplicate-domain.yaml
      exitCode: 1
      outputContains: "spec.domain must be unique"
FieldRequiredDescription
filenoPath to a manifest file. Relative paths resolve from the e2e.yaml directory. Mutually exclusive with inline
inlinenoRaw YAML or JSON manifest applied via stdin. Mutually exclusive with file
namespacenoNamespace override for resources that don’t declare one
exitCodenoExpected exit code. Default 0 (success) — set non-zero to assert the apply must be rejected (e.g. an admission webhook denial) rather than treating any failure as a broken test
equals, notEquals, outputContains, outputNotContains, regex, greaterThan, lessThan, greaterThanOrEqual, lessThanOrEqual, between, notBetween, exists, notExists, oneOf, notOneOfnoAssertions on the combined stdout+stderr — same fields and semantics as commands:

Combined stdout+stderr is captured the same way regardless of exitCode — a webhook’s denial message lands in kubectl’s stderr, so outputContains can assert on the actual rejection reason, not just that the apply failed.


kubectl.delete

Deletes resources during an expect checkpoint. Use file to delete all resources in a manifest, or kind + name for a single resource. file and kind/name are mutually exclusive.

Generates: kubectl delete -f <file> or kubectl delete <kind> <name> -n <namespace>

kubectl:
  delete:
    # delete all resources in a manifest
    - file: ./crd.yaml

    # delete a single resource by identity
    - kind: Pod
      name: my-pod
      namespace: default
      ignoreNotFound: true
FieldRequiredDescription
fileone of file or kind+namePath to a manifest file. Relative paths resolve from the e2e.yaml directory
kindone of file or kind+nameKubernetes resource kind
nameone of file or kind+nameResource name
namespacenoNamespace to target. Defaults to default
ignoreNotFoundnoSilences errors when the resource does not exist

kubectl.patch

Patches a Kubernetes resource in-place. Useful for triggering state transitions — driving a state machine forward, updating a field to test a reconciler’s reaction, etc.

Generates: kubectl patch <kind> <name> -n <namespace> --type=<type> -p '<patch>'

kubectl:
  patch:
    # merge patch (default) — scale up replicas
    - kind: Deployment
      name: my-service
      namespace: default
      patch: '{"spec":{"replicas":3}}'

    # strategic merge patch — update a container image
    - kind: Deployment
      name: my-service
      namespace: default
      type: strategic
      patch: |
        spec:
          template:
            spec:
              containers:
              - name: app
                image: my-service:v2

    # json patch — set a specific field by path
    - kind: MyResource
      name: my-resource
      namespace: default
      type: json
      patch: '[{"op":"replace","path":"/spec/phase","value":"active"}]'
FieldRequiredDescription
kindyesKubernetes resource kind
nameyesResource name
namespacenoNamespace. Default: default
typenoPatch strategy: merge (default), strategic, or json
patchyesPatch content as a YAML or JSON string

kubectl.events

Lists Kubernetes events for a specific resource and asserts the output. Useful for verifying that the operator emitted expected events or that no error events occurred.

Generates: kubectl events --for=<kind>/<name> -n <namespace>

kubectl:
  events:
    # assert the operator emitted a Reconciled event
    - kind: Deployment
      name: my-service
      namespace: default
      outputContains: Reconciled

    # assert no BackOff events occurred
    - kind: Pod
      name: my-service-abc123
      namespace: default
      outputNotContains: BackOff
FieldRequiredDescription
kindyesKubernetes resource kind
nameyesResource name
namespacenoNamespace. Default: default

kubectl.auth

Checks permissions via kubectl auth can-i and asserts the result (yes or no). Useful for verifying that the operator created the correct RBAC resources — ServiceAccounts, ClusterRoles, ClusterRoleBindings.

Generates: kubectl auth can-i <verb> <resource> [-n <namespace>] [--as <as>]

kubectl:
  auth:
    # assert the operator's service account can list pods
    - verb: list
      resource: pods
      namespace: default
      as: system:serviceaccount:default:my-operator
      equals: "yes"

    # assert it cannot delete secrets (principle of least privilege)
    - verb: delete
      resource: secrets
      namespace: default
      as: system:serviceaccount:default:my-operator
      equals: "no"
FieldRequiredDescription
verbyesAction to check: get, list, create, delete, patch, etc.
resourceyesKubernetes resource type: pods, deployments, secrets, etc.
namespacenoNamespace scope. Omit for cluster-scoped checks
asnoUser or service account to impersonate. Use system:serviceaccount:<ns>:<name> form

kubectl.cp

Copies a file out of a running container and asserts its content. Resolves the pod by name or label selector, copies to a temporary path, applies assertions, and cleans up. Supports jq and yq extraction for structured file content.

Generates: kubectl cp <ns>/<pod>:<src> <tempfile>

kubectl:
  cp:
    # assert a generated config file contains the expected value
    - labelSelector: app=my-service
      namespace: default
      src: /etc/config/app.conf
      outputContains: "maxConnections=100"

    # assert a JSON file field via jq
    - labelSelector: app=my-service
      namespace: default
      src: /etc/config/settings.json
      jq: .database.host
      equals: "postgres.default.svc"

    # assert from a named pod with a specific container
    - name: my-service-abc123
      container: app
      namespace: default
      src: /tmp/generated-cert.pem
      outputContains: "BEGIN CERTIFICATE"
FieldRequiredDescription
namenoPod name. Use labelSelector to match by label instead
labelSelectornoLabel selector. One of name or labelSelector required
namespacenoNamespace. Default: default
containernoContainer name. Defaults to the first container
srcyesPath inside the container to copy from
jqnojq expression applied to the file content before asserting
yqnoyq expression applied to the file content before asserting

kubectl.top

Queries live CPU and memory usage via kubectl top and asserts the output. Requires metrics-server; the runner installs it automatically via Helm when any top entry is present. On kind clusters, --kubelet-insecure-tls is set automatically.

Generates: kubectl top <kind> [-n <namespace>] [<name> | -l <selector>] [--containers]

kubectl:
  top:
    # assert both probe pods appear in metrics output
    - kind: pod
      namespace: default
      labelSelector: app=my-service
      outputContains: my-service

    # assert a specific pod's metrics row is present
    - kind: pod
      name: my-service-abc123
      namespace: default
      outputContains: my-service-abc123

    # per-container breakdown
    - kind: pod
      namespace: default
      labelSelector: app=my-service
      containers: true
      outputContains: app

    # assert node metrics are available
    - kind: node
      outputContains: cpu
FieldRequiredDescription
kindyesResource type: pod (or pods) or node (or nodes)
namenoPod or node name. Omit to list all
labelSelectornoFilter pods by label. Applies to pods only
namespacenoNamespace. Applies to pods only. Default: default
containersnoShow per-container metrics (--containers). Pods only

Tool pre-flight

When ork e2e loads the spec, it scans for tool requirements and installs missing ones before assertions run:

ToolRequired whenInstalled via
curlAny port-forward entry has a pathapt-get / apk / brew
jqAny entry has a jq: fieldapt-get / apk / brew
yqAny entry has a yq: fieldapt-get / apk / brew
metrics-serverAny top entry is presentHelm (../metrics-server/metrics-server)

Installation is automatic. A spinner shows progress. On kind clusters, metrics-server is installed with --kubelet-insecure-tls automatically.


Combining with resources: and commands:

All three blocks work together in the same checkpoint:

expect:
  - name: Service is healthy and correctly configured
    after: cr-applied
    timeout: 90s

    resources:
      - kind: Deployment
        name: my-service
        namespace: default
        ready: true

    kubectl:
      get:
        - kind: Deployment
          name: my-service
          field: .spec.template.spec.containers[0].resources.requests.cpu
          equals: 200m
      logs:
        - labelSelector: app=my-service
          outputContains: "ready to serve"
          outputNotContains: FATAL

    commands:
      - run: "curl -sf http://my-service:8080/healthz"
        outputContains: ok

kubectl.restart

Trigger a rollout restart of a Deployment, StatefulSet, or DaemonSet. By default waits for the rollout to complete — the expect step’s timeout governs how long.

FieldTypeRequiredDescription
kindstringyesResource kind: Deployment, StatefulSet, or DaemonSet
namestringyesResource name
namespacestringnoNamespace. Defaults to default
readyboolnoWait for rollout to complete. Defaults to true
kubectl:
  restart:
    - kind: Deployment
      name: orkestra-gateway
      namespace: orkestra-system

kubectl.scale

Set the replica count on a Deployment, StatefulSet, or ReplicaSet. By default waits for the rollout to complete — the expect step’s timeout governs how long.

FieldTypeRequiredDescription
kindstringyesResource kind: Deployment, StatefulSet, or ReplicaSet
namestringyesResource name
namespacestringnoNamespace. Defaults to default
replicasintyesDesired replica count
readyboolnoWait for rollout to complete. Defaults to true
kubectl:
  scale:
    - kind: Deployment
      name: my-app
      namespace: default
      replicas: 3

→ Back: 06-discovery | Schema index