Skip to content

Kubernetes (kubectl)

Real kubectl-observed cluster state, discovered by real labels — see BDD conventions for the underlying pattern.

Discovery

Deployment, Service, Pod, ConfigMap, ReplicaSet, and Secret are all discovered the same way — by real label selector, never a guessed/computed name. Each runs a real, read-only kubectl get at construction time. namespace is the one recognized non-label field; every other row is an arbitrary label key/value used to build a -l selector. Requires the selector to match exactly one real object; 0 or 2+ matches is a loud error (Expected exactly one <kind> matching ...).

Deployment

Given Deployment "<NginxDeployment>"
And "<NginxDeployment>" namespace is "thomas-helm-test"
And "<NginxDeployment>" label "app.kubernetes.io/name" is "test-nginx"
And "<NginxDeployment>" label "app.kubernetes.io/instance" is "thomas-nginx-k8s-release"
Given Deployment known as "<NginxDeployment>":
  | PROPERTY                   | VALUE                     |
  | namespace                  | thomas-helm-test          |
  | app.kubernetes.io/name     | test-nginx                |
  | app.kubernetes.io/instance | thomas-nginx-k8s-release  |

Both forms run the identical real, one-time kubectl get — see Progressive Discovery below for how the short form resolves lazily. The two are otherwise interchangeable; pick whichever reads better for the number of labels a scenario needs.

When I upgrade Helm Release known as "<NginxRelease>" with:
  | OPTION             | VALUE |
  | --install          | True  |
  | --atomic            | True  |
  | --create-namespace   | True  |
Then the command exited with 0

Given Deployment known as "<NginxDeployment>":
  | PROPERTY                   | VALUE                     |
  | namespace                  | thomas-helm-test        |
  | app.kubernetes.io/name     | test-nginx                |
  | app.kubernetes.io/instance | thomas-nginx-k8s-release |

Deployment/Service are always discovered after an --atomic helm upgrade, so they're already stable by the time this runs — a one-shot get is safe.

Service

Given Service "<RestApiService>"
And "<RestApiService>" namespace is "thomas-helm-test"
And "<RestApiService>" label "app.kubernetes.io/instance" is "thomas-rest-api-release"
Given Service known as "<NginxService>":
  | PROPERTY                   | VALUE                     |
  | namespace                  | thomas-helm-test          |
  | app.kubernetes.io/name     | test-nginx                |
  | app.kubernetes.io/instance | thomas-nginx-k8s-release  |
Given Service known as "<NotesService>":
  | PROPERTY                   | VALUE                 |
  | namespace                  | thomas-helm-test    |
  | app.kubernetes.io/instance | thomas-notes-release |
And HTTP Endpoint "<NotesApi>" on "<NotesService>" port "8000"

A Service wrapped as an HTTP/HTTPS Endpoint — the same kind of exception as HelmRelease.chart, resolving to the real object instead of a string.

Pod

A Pod is the one type in this family without an --atomic-style stability guarantee — see Polling below for the full mechanism.

Given Pod "<NginxPod>"
And "<NginxPod>" namespace is "thomas-helm-test"
And "<NginxPod>" label "app.kubernetes.io/name" is "test-nginx"
And "<NginxPod>" label "app.kubernetes.io/instance" is "thomas-nginx-k8s-release"
Given Pod known as "<NginxPod>":
  | PROPERTY                   | VALUE                     |
  | namespace                  | thomas-helm-test          |
  | app.kubernetes.io/name     | test-nginx                |
  | app.kubernetes.io/instance | thomas-nginx-k8s-release  |
Given Pod known as "<NginxPod>":
  | PROPERTY                   | VALUE                     |
  | namespace                  | thomas-helm-test        |
  | app.kubernetes.io/name     | test-nginx                |
  | app.kubernetes.io/instance | thomas-nginx-k8s-release |
When I poll Pod known as "<NginxPod>" every "3s" for up to "30s" until:
  | KEY           | CONDITION | VALUE   | OUTCOME |
  | status.phase  | equals    | Running | pass    |

ConfigMap

No logs (same as Service — see Get Logs below). Unlike ReplicaSet, a chart's ConfigMap is not versioned per revision, so it's safe to discover regardless of how many times the owning release has been upgraded.

Given ConfigMap "<RestApiConfigMap>"
And "<RestApiConfigMap>" namespace is "thomas-helm-test"
And "<RestApiConfigMap>" label "app.kubernetes.io/instance" is "thomas-rest-api-release"
When I get ConfigMap "<RestApiConfigMap>" as JSON
Then the command result has "data.\"notes.py\"" exists
Given ConfigMap known as "<RestApiConfigMap>":
  | PROPERTY                   | VALUE                    |
  | namespace                  | thomas-helm-test          |
  | app.kubernetes.io/instance | thomas-rest-api-release   |
When I get ConfigMap known as "<RestApiConfigMap>" with:
  | OPTION   | VALUE |
  | --output | json  |
Then the command result data has:
  | KEY             | CONDITION | VALUE |
  | data."notes.py" | exists    |       |

Creating a ConfigMap from a Real File

When I create ConfigMap known as {string} named {string} in {string} from file {string} at {string}

Every ConfigMap above is chart-managed — helm uninstall removes it automatically. This is the exception: a real kubectl create configmap ... --from-file=<key>=<path> for content a chart needs mounted but has no values field for (e.g. seeding a CA cert into an app via a generic extraVolumes/extraVolumeMounts escape hatch, paired with extraEnv to point something like GIT_SSL_CAINFO at it). It has no ownerReference back to anything Helm tracks, so — unlike every other kind here — clean it up explicitly:

When I create ConfigMap known as "<CaCertConfigMap>" named "git-tls-ca" in "<Namespace>" from file "ca-cert.pem" at ".cache/fixtures/git-tls/ca-cert.pem"
Then the command exited with 0
...
When I delete ConfigMap known as "<CaCertConfigMap>"
Then the command exited with 0

Registers into the same alias map discovery uses, so the generic When I delete {word} known as {string} step is the real cleanup — not a second, bespoke step. Both namespace and the trailing <path> are soft-substituted, so either can embed a captured value (e.g. a worker-scoped path segment under cucumber-js's own --parallel), same as namespace already did.

ReplicaSet

Only safe against a release installed once and never upgraded again

A Deployment keeps its old ReplicaSets around by default (revisionHistoryLimit), and every one of them — old or current — carries the same app.kubernetes.io/name/instance labels as the Deployment itself. Discovering a ReplicaSet against a release that's been upgraded or rolled back more than once genuinely matches more than one real object and fails the same way any other ambiguous selector does (Expected exactly one replicaset matching ...). Only use it against a release installed once, like <NginxRelease> below — never against one that's been rolled back, like <RollbackRelease> in Helm: Manage a Release.

Given ReplicaSet "<NginxReplicaSet>"
And "<NginxReplicaSet>" namespace is "thomas-helm-test"
And "<NginxReplicaSet>" label "app.kubernetes.io/name" is "test-nginx"
And "<NginxReplicaSet>" label "app.kubernetes.io/instance" is "thomas-nginx-k8s-release"
When I get ReplicaSet "<NginxReplicaSet>" as JSON
Then the command result has "status.replicas" is 1
Given ReplicaSet known as "<NginxReplicaSet>":
  | PROPERTY                   | VALUE                     |
  | namespace                  | thomas-helm-test          |
  | app.kubernetes.io/name     | test-nginx                |
  | app.kubernetes.io/instance | thomas-nginx-k8s-release  |
When I get ReplicaSet known as "<NginxReplicaSet>" with:
  | OPTION   | VALUE |
  | --output | json  |
Then the command result data has:
  | KEY             | CONDITION | VALUE |
  | status.replicas | equals    | 1     |

Secret

No logs (same as Service/ConfigMap). Unlike the other kinds here, a Secret created by a controller reacting to some other resource (rather than templated directly by the chart) isn't guaranteed to exist the instant this runs — see TLS Certificates below, where discovery retries for exactly that reason.

Secret's short form is a Payload accumulator, not progressive discovery

Progressive discovery (used by every other kind above) resolves through discoverByFields directly, on first use — bypassing Secret's own bounded retry entirely. The Payload accumulator instead flows through secretFromFields, the same real retry-bearing function the table form calls, so the short form doesn't lose correctness for speed.

Given "<TlsDemoSecretPayload>" field "namespace" is "thomas-helm-test"
And "<TlsDemoSecretPayload>" field "app.kubernetes.io/instance" is "thomas-tls-demo-release"
Given Secret known as "<TlsDemoSecret>" using "<TlsDemoSecretPayload>"
Given Secret known as "<TlsDemoSecret>":
  | PROPERTY                   | VALUE                     |
  | namespace                  | thomas-helm-test          |
  | app.kubernetes.io/instance | thomas-tls-demo-release   |

helm uninstall only removes what Helm itself templated — a Secret created by another controller (cert-manager, here) has no ownerReference back to it and is left behind. Clean it up explicitly:

When I delete Secret known as "<TlsDemoSecret>"
Then the command exited with 0

I delete {word} known as "<Alias>" works generically for any discovered kind, not just Secret — it exists specifically for cases like this one where Helm doesn't own the cleanup.

Creating a Secret from a Real File

When I create Secret known as {string} named {string} in {string} from file {string} at {string}

The Secret sibling of Creating a ConfigMap from a Real File above — a real kubectl create secret generic ... --from-file=<key>=<path>, for content no chart values field can express. Distinct from a chart's own self-managed Secret built from array-index --set/--set-file values (e.g. gitCredentials.entries[0].privateKey=<path>, which Helm's own template renders into a well-formed JSON array) — this one is for seeding deliberately arbitrary or malformed real content (e.g. a negative-path "malformed credentials file" scenario), which no chart value shape can produce:

When I create File known as "<BadCredsFile>" at ".cache/fixtures/bad-git-creds.json" with:
  """
  { this is not valid JSON
  """
When I create Secret known as "<BadCredsSecret>" named "bad-git-creds" in "<Namespace>" from file "git-credentials.json" at ".cache/fixtures/bad-git-creds.json"
Then the command exited with 0

Same real gap as the ConfigMap form: no ownerReference back to any Helm release, so I delete Secret known as {string} is the real cleanup.

Progressive Discovery

A second, additive construction style for any kind above — the same real, one-time kubectl get, just spread across several Given lines instead of one table, for when that reads better than a table with only one or two rows:

Given Deployment "<NginxDeployment>"
And "<NginxDeployment>" namespace is "dev"
And "<NginxDeployment>" label "app.kubernetes.io/instance" is "nginx-release"

No real command runs until the object is actually used by a later When/Then step — with this style the full selector genuinely isn't known until the last And line, so the real check happens then, with the same "expected exactly one match" error as the table form. The table form is unchanged and still resolves immediately; use whichever reads better for the number of predicates a scenario actually needs.

Waiting for a fire-and-forget rollout's object to become discoverable

When I wait for Pod known as "<Resource>" every "<interval>" for up to "<timeout>"

That first real discovery (whichever style built it) is still eager and one-shot — fine when something already waited for real readiness first (--atomic/--wait), wrong for a deliberate fire-and-forget rollout (no --wait): the object may genuinely not exist yet the instant helm upgrade returns. This retries real discovery itself — not a field of an already-known object — tolerating "not found yet" until it succeeds or the timeout elapses:

Given Pod "<AppPod>"
And "<AppPod>" namespace is "dev"
And "<AppPod>" label "app.kubernetes.io/instance" is "my-release"
When I wait for Pod known as "<AppPod>" every "2s" for up to "30s"

Works for any kind in the Discovery section above, not just Pod. Pairs naturally with polling the object's own fields right after (e.g. status.podIP) — see Polling below.

Querying

Reading a discovered object's current state, events, or logs. Works the same for every kind above.

Get an Object

When I get {word} known as "<Alias>"
When I get {word} known as "<Alias>" with:
When I get {word} "<Alias>" with {flags}
When I get {word} "<Alias>" as {outputFormat}

Runs kubectl get <kind> <name> -n <namespace> plus any supplied options — {word} is the kind (Deployment, Service, Pod, ConfigMap, ReplicaSet). Since --output json/--output yaml alone is by far the most common real case, there's a dedicated as {outputFormat} shorthand for exactly that, alongside the general with {flags} short form for anything else:

When I get Deployment "<NginxDeployment>" as JSON
Then Deployment "<NginxDeployment>" has "status.readyReplicas" >= 1
When I get Deployment known as "<NginxDeployment>" with:
  | OPTION   | VALUE |
  | --output | json  |
Then the command result data has:
  | KEY                                          | CONDITION | VALUE |
  | status.readyReplicas                         | equals    | 1     |
  | status.readyReplicas                         | gte       | 1     |
  | metadata.labels."app.kubernetes.io/version"  | equals    | 1.27  |

The Then in the short form is object-flavored — see Object-Flavored Assertions below.

Get Events

When I get events for {word} known as "<Alias>"
When I get events for {word} known as "<Alias>" with:
When I get events for {word} "<Alias>" with {flags}

Runs kubectl get events filtered to the object via --field-selector involvedObject.name=<name>,involvedObject.kind=<Kind>.

When I get events for Deployment "<NginxDeployment>" with --show-kind
Then the command exited with 0 STDOUT contains "Scaled up replica set"
When I get events for Deployment known as "<NginxDeployment>" with:
  | OPTION      | VALUE |
  | --show-kind | True  |
Then the command exited with 0:
  | SOURCE | CONDITION | VALUE                 |
  | STDOUT | contains  | Scaled up replica set |

For asserting on event content as it becomes available rather than a one-shot snapshot, pair this with a poll instead — see Poll Logs or Events for a Pod. The table-less form (nothing to shorten) works identically in both syntaxes:

When I get events for Deployment known as "<NginxDeployment>"
Then the command exited with 0

Get Logs

When I get logs for {word} known as "<Alias>"
When I get logs for {word} known as "<Alias>" with:
When I get logs for {word} "<Alias>" with {flags}

Runs kubectl logs <kind>/<name> -n <namespace> — not available for Service/ConfigMap, which have no logs.

When I get logs for Deployment "<NginxDeployment>" with --timestamps
Then the command exited with 0 STDOUT contains "Configuration complete; ready for start up"
When I get logs for Deployment known as "<NginxDeployment>" with:
  | OPTION       | VALUE |
  | --timestamps | True  |
Then the command exited with 0:
  | SOURCE | CONDITION | VALUE                                      |
  | STDOUT | contains  | Configuration complete; ready for start up |

The table-less form (nothing to shorten) works identically in both syntaxes:

When I get logs for Deployment known as "<NginxDeployment>"
Then the command exited with 0

Object-Flavored Assertions

Then {word} "<Alias>" has "<key>" <condition> <value>

A sibling of the command result has ... named after the object instead — still only ever reads the same captured result from the When I get ... step right above it, never a new real check. The value it adds: it verifies "<Alias>" is a genuinely registered object of that kind first, throwing if it isn't — catching a copy-paste kind/alias mismatch before comparing anything.

When I get Deployment "<NginxDeployment>" as JSON
Then Deployment "<NginxDeployment>" has "status.readyReplicas" >= 1

<condition> accepts either word (gte) or symbol (>=) form — real aliases for the same check, not two different vocabularies; see BDD conventions.

Polling

Real, repeated checks for a Pod's eventually-consistent state — the one alias type without an --atomic-style stability guarantee.

Poll a Pod

When I poll Pod known as "<Alias>" every "<interval>" for up to "<timeout>" until:

A Pod can be Pending for a real, variable time. This re-runs a real kubectl get pod -o json every interval (never re-checks stale data) until every pass-OUTCOME row holds (success), any fail-OUTCOME row holds (immediate failure, no need to exhaust the timeout on a state that's already terminal-bad), or the timeout elapses (failure, with the last real observed state reported). There is also When I attempt to poll Pod ... (wraps the poll for negative tests).

cucumber-js's own default step timeout is 5000ms

Shorter than a legitimate real poll used in this suite (up to 2 minutes for the fast-fail example below). This is already handled globally (support/hooks.ts raises it via setDefaultTimeout) — if you add a new poll with a longer real timeout than anything existing, check that value is still comfortably above your new longest real wait; don't assume it's covered forever.

When I poll Pod known as "<NginxPod>" every "3s" for up to "30s" until:
  | KEY                                              | CONDITION | VALUE            | OUTCOME |
  | status.phase                                     | equals    | Running          | pass    |
  | status.containerStatuses[0].ready                | equals    | true             | pass    |
  | status.containerStatuses[0].state.waiting.reason | equals    | CrashLoopBackOff | fail    |
When I attempt to poll Pod known as "<BadImagePod>" every "3s" for up to "2m" until:
  | KEY                                              | CONDITION | VALUE            | OUTCOME |
  | status.phase                                     | equals    | Running          | pass    |
  | status.containerStatuses[0].state.waiting.reason | equals    | ErrImagePull     | fail    |
  | status.containerStatuses[0].state.waiting.reason | equals    | ImagePullBackOff | fail    |
Then it should have failed with either:
  | MESSAGE          |
  | ErrImagePull     |
  | ImagePullBackOff |

The preceding install deliberately omits --atomic, which would otherwise block and auto-roll-back before this could observe the bad state. A real bad state can genuinely be observed as more than one real message across ticks — pair a fast-fail poll with it should have failed with either:, not the single-message form.

Poll Logs or Events for a Pod

When I poll logs for Pod known as "<Alias>" every "<interval>" for up to "<timeout>" until:

Same polling mechanism, but against raw kubectl logs/get events text instead of structured JSON — uses SOURCE|CONDITION|VALUE|OUTCOME rows (SOURCE is STDOUT/STDERR) instead of KEY.

When I poll logs for Pod known as "<NginxPod>" every "3s" for up to "15s" until:
  | SOURCE | CONDITION | VALUE                                       | OUTCOME |
  | STDOUT | contains  | Configuration complete; ready for start up | pass    |
When I poll events for Pod known as "<NginxPod>" every "3s" for up to "15s" until:
  | SOURCE | CONDITION | VALUE   | OUTCOME |
  | STDOUT | contains  | Started | pass    |
  | STDOUT | contains  | BackOff | fail    |

Poll Structured Events for a Pod

When I poll structured events for Pod known as "<Alias>" every "<interval>" for up to "<timeout>" until:

The raw-text form above only ever substring-matches the whole combined kubectl get events output — it can't tell a Reason value apart from a Message value, only as accidentally-adjacent substrings of the same blob. This structured sibling reuses the same JSON-polling mechanism poll Pod uses, fed from kubectl get events -o json instead, so Reason/Message become real, separately-queryable KEYs (items[*].reason/items[*].message — array projection over the real events list, existential by default: passes if any event matches).

When I poll structured events for Pod known as "<EventsPod>" every "3s" for up to "60s" until:
  | KEY               | CONDITION | VALUE                 | OUTCOME |
  | items[*].reason    | contains  | Unhealthy             | pass    |
  | items[*].message   | contains  | Startup probe failed  | pass    |

Exercised against a real, transient startup-probe failure in features/k8s/events-{short,full}.feature: charts/test-rest-api installed without --atomic genuinely produces a real Unhealthy event with a Startup probe failed message during its own uncached pip install window — confirmed live via kubectl get events -o json before writing the scenario — followed by a real recovery once the app finishes starting.

A Service can't route to a Pod it just excluded

Don't expect an error status from an already-NotReady Pod's Service

Once a Pod is confirmed NotReady (proven via a Pod-status poll), it has been removed from its Service's endpoints entirely. Don't write a follow-up REST request expecting a real error status from that Service — the request fails to connect, it doesn't return the app's own error response. Assert the readiness state directly instead:

When I poll Pod known as "<ReadinessPod>" every "3s" for up to "30s" until:
  | KEY                                      | CONDITION | VALUE | OUTCOME |
  | status.containerStatuses[0].ready        | equals    | false | pass    |
  | status.containerStatuses[0].restartCount | equals    | 0     | pass    |

Exec Into a Pod

When I exec "<command>" in Pod known as "<Alias>"

For the checks get/logs/events genuinely can't answer from the outside — a mounted file's real content, an env var, whether a process is actually running. Runs a real kubectl exec <pod> -n <namespace> -- sh -c "<command>", piped through a real shell so pipes/redirects in <command> work unmodified. Result lands in the same lastCommandResult slot as every other command, so the existing the command exited with {int}[:] steps assert on it unchanged — no new vocabulary.

When I exec "cat /etc/nginx/conf.d/default.conf" in Pod known as "<NginxPod>"
Then the command exited with 0:
  | SOURCE | CONDITION | VALUE        |
  | STDOUT | contains  | location / { |

Checking RBAC Permissions

When I check if Pod known as "<Alias>" can "<verb>" "<resource>"

A different axis from everything above — permissions, not state. Fetches the Pod's real spec.serviceAccountName with a fresh kubectl get pod -o json (not cached from discovery — this checks the account actually running the workload right now), then runs a real kubectl auth can-i <verb> <resource> --as=system:serviceaccount:<namespace>:<serviceAccount> -n <namespace>. kubectl auth can-i is itself the real assertion mechanism — it exits 0 when allowed, 1 when denied — so the command exited with {int} already covers "was this allowed", zero new vocabulary.

When I check if Pod known as "<NginxPod>" can "create" "deployments"
Then the command exited with 1

This Pod's real ServiceAccount is the namespace's default (the chart declares none), which genuinely cannot create Deployments under standard RBAC — a real, true assertion, not a fixture rigged to fail.

Labeling a Namespace

When I label namespace "<name>" with:
When I label namespace "<name>" with {flags}

Stateless — no alias registered or resolved (a namespace, usually one just created via --create-namespace on a Helm install, has no object of its own in this suite). name goes through the same soft captured-value substitution as any other namespace field, so a dynamically-computed namespace works here too. Runs a real kubectl label namespace <name> ...args.

kubectl label's real argv is KEY=VALUE as one token, not --flag value — write the label as a positional (blank OPTION) row, not an OPTION row:

When I label namespace "<Namespace>" with:
  | OPTION      | VALUE                                         |
  |             | pod-security.kubernetes.io/enforce=privileged |
  | --overwrite | True                                          |
Then the command exited with 0

The real, motivating use: a per-run disposable BuildKit instance needs privileged (or rootless-unconfined) execution, which a cluster's default baseline PodSecurity level blocks — labeling the namespace this way before installing BuildKit into it is what unblocks that. A trailing - on the key removes it (kubectl label's own real syntax):

When I label namespace "<Namespace>" with --overwrite thomas.test/label-

TLS Certificates

When I inspect the certificate in Secret known as "<Alias>"

Extracts data."tls.crt" from a real kubernetes.io/tls Secret's real JSON, base64-decodes it to a real temp file, and runs a real openssl x509 -in <path> -noout -subject -enddate -ext subjectAltName against it — real openssl, not a bespoke ASN.1 parser. Result lands in lastCommandResult, so the command exited with {int}: plus SOURCE|CONDITION|VALUE against the real, parsed subject/expiry/SANs needs no new assertion vocabulary either.

Given Directory "<TlsDemoChartDirectory>" at "./charts/test-tls-demo"
And Helm Chart "<TlsDemoHelmChart>" in "<TlsDemoChartDirectory>"
And Helm Release "<TlsDemoRelease>" of "<TlsDemoHelmChart>" named "thomas-tls-demo-release" in "thomas-helm-test"
When I upgrade Helm Release known as "<TlsDemoRelease>" with:
  | OPTION             | VALUE |
  | --install          | True  |
  | --atomic           | True  |
  | --create-namespace | True  |
Then the command exited with 0

Given Secret known as "<TlsDemoSecret>":
  | PROPERTY                   | VALUE                    |
  | namespace                  | thomas-helm-test         |
  | app.kubernetes.io/instance | thomas-tls-demo-release |
When I inspect the certificate in Secret known as "<TlsDemoSecret>"
Then the command exited with 0:
  | SOURCE | CONDITION | VALUE                            |
  | STDOUT | contains  | CN=demo.thomas-helm-test.local   |
  | STDOUT | contains  | DNS:demo.thomas-helm-test.local  |

This cluster's real cert-manager talks to a real, production ACME account

charts/test-tls-demo/ deliberately uses a namespaced, selfSigned Issuer — no ACME, no DNS-01, no shared rate limit. This cluster's default ClusterIssuer is wired to a real Let's Encrypt account with real AWS Route53 credentials; a fixture chart requesting a certificate from that issuer would issue a real production certificate and touch real DNS on every test run. Follow the same pattern (a scenario-local selfSigned Issuer) for any new TLS-testing fixture rather than pointing at the cluster's real one. charts/test-rest-api/'s own optional values.tls.enabled listener (see REST: Define an HTTPS Endpoint) follows this exact same pattern.

The same data["tls.crt"] this section decodes with openssl is also what an HTTPS Endpoint trusts a real server certificate against — a self-signed cert is its own CA, so no separate ca.crt field or Secret shape is needed for that.