Skip to main content

Kubernetes Activities

Eight activities for orchestrating a Kubernetes cluster: k8s.apply, k8s.get, k8s.list, k8s.delete, k8s.scale, k8s.logs, k8s.wait and k8s.exec.

They speak to the cluster's REST API directly — no kubectl binary and no kubeconfig on the worker. Every activity names its cluster inline, so one workflow can target several clusters, and resources are resolved through API discovery, which means custom resources work exactly like built-in ones.

Setup

Every activity takes a connection object. Build it once in context: and reuse it, rather than repeating it per step.

FieldTypeRequiredDefaultDescription
api_serverstryesAPI server base URL. Must be https://
token_secret_keystrone-ofSecret holding a bearer token
client_cert_secret_keystrone-ofSecret holding a client certificate (PEM)
client_key_secret_keystrone-ofSecret holding the client private key (PEM)
ca_certstrnonullCA bundle as inline PEM
ca_cert_secret_keystrnonullSecret holding the CA bundle. Exclusive with ca_cert
insecure_skip_tls_verifyboolnofalseSkip certificate verification
namespacestrno"default"Namespace used when an activity does not name one

Exactly one authentication method is required: a bearer token, or both of the client certificate and key. An unauthenticated connection is not expressible.

context:
k8s: {}

# ...
- transform:
name: prepare
output_data:
- k8s:
api_server: "https://my-cluster.example.com:6443"
token_secret_key: "K8S_PROD_TOKEN"
ca_cert_secret_key: "K8S_PROD_CA"
namespace: "payments"

Store the credentials once with builtin.secret.upload.

ca_cert is the one field that may be inline

Tokens and client keys have no inline form — they must be named through a *_secret_key. A CA certificate is public material, so forcing it through the secret store buys nothing, and ca_cert accepts PEM directly. See Secrets.

Client certificates and CA bundles are written to the worker's filesystem

The Kubernetes client accepts TLS material only as file paths, so a connection using ca_cert/ca_cert_secret_key or client-certificate auth writes those PEMs into a private (0700) temporary directory for the lifetime of the connection, removed when the worker shuts down. A bearer token never touches disk — it lives only in memory. Deployments that cannot accept certificates on disk should use token auth against a publicly-trusted endpoint.

Connections are cached per worker and keyed on the resolved credentials, so rotating a secret transparently produces a new connection rather than pinning a revoked token.


Safety

k8s.apply, k8s.delete and k8s.exec can destroy a production cluster. Two independent mechanisms guard them.

1. Authorization policy. Every activity asserts a privilege on the internal.k8s_activities resource, with a per-verb action so a deployment can grant broad read-only access and narrow write access:

ActionActivities
readk8s.get, k8s.list, k8s.logs, k8s.wait
writek8s.apply, k8s.scale
deletek8s.delete
execk8s.exec

The evidence offered to the policy includes api_server_host, namespace, kind, k8s_action, cluster_scoped and insecure_skip_tls_verify — so a policy can say things like "production workflows may only reach the production cluster" or "only these two wfspecs may exec". The policy itself is supplied by your deployment through the internal.authz_policies workflow, not by a file in this repository. See Authz Activities.

If no policy is deployed, the check passes

Like every internal.* privilege in Moco, an unresolvable policy is logged and allowed. Do not treat "k8s activities are authorization-gated" as protection until you have actually deployed a policy for internal.k8s_activities.

2. Hard guards, always on. These need no configuration and cannot be turned off except by an explicit per-call flag:

  • k8s.delete refuses a cluster-scoped resource (Namespace, PersistentVolume, CRD, ClusterRole, …) unless allow_cluster_scoped: true. Scope comes from API discovery, so it is correct for custom resources too.
  • k8s.delete refuses a label-selector delete unless allow_bulk_delete: true, and rejects an empty selector outright — label_selector: "" must never mean "everything".
  • k8s.apply and k8s.delete refuse kube-system, kube-public and kube-node-lease unless allow_system_namespaces: true.
  • k8s.apply rejects a document with no kind, no apiVersion, or no metadata.name/generateName, naming the offending document by index.
  • k8s.exec takes a command list; there is no implicit shell.

k8s.apply

Applies one or more manifests using server-side apply, so the API server arbitrates field ownership and a conflict with another controller (an HPA owning replicas, Argo owning the spec) is a loud error rather than a silent overwrite.

A string manifest may contain several ----separated documents. They are applied in the order given — that is how you express Namespace-before-workload or CRD-before-custom-resource. There is no dependency sorting.

Input

FieldTypeRequiredDefaultDescription
connectionobjectyesCluster connection
manifeststr | object | listyesYAML text, one document, or a list of documents
namespacestrnoconnection'sNamespace for documents that do not name one
field_managerstrno"moco"Server-side-apply field manager
force_conflictsboolnofalseTake ownership of fields another manager owns
dry_runboolnofalseValidate and admit without persisting
continue_on_errorboolnofalseApply every document, recording per-document errors
allow_system_namespacesboolnofalsePermit kube-system and friends

Output

FieldTypeDescription
resourceslistOne entry per document, in input order
resources[].api_versionstrapiVersion of the document
resources[].kindstrKind of the document
resources[].namestr | nullName as returned by the API server
resources[].namespacestr | nullNamespace; null when cluster-scoped
resources[].uidstr | nullmetadata.uid
resources[].resource_versionstr | nullmetadata.resourceVersion after the apply
resources[].errorstr | nullFailure message; only set when continue_on_error is true
applied_countintNumber of documents applied without error

By default the first failing document stops the apply and raises, naming the document's index and listing what had already been applied — because a half-mutated cluster is something the workflow needs to know about precisely.

- activity:
type: k8s.apply
name: create-job
input_data:
connection: "{{ k8s }}"
field_manager: "moco-migrate"
manifest:
apiVersion: batch/v1
kind: Job
metadata:
name: "{{ job_name }}"
spec:
backoffLimit: 0
template:
spec:
restartPolicy: Never
containers:
- name: migrate
image: "busybox:1.36"
command: ["/bin/sh", "-c", "echo migrating; sleep 5"]
output_name: created
k8s.apply runs once — it is not retried

Its default policy is max_attempts: 1. Server-side apply is itself idempotent, but a multi- document bundle that fails at document 5 would re-apply documents 1–4 on a retry. If your bundle is a single document, or purely declarative, raise max_attempts per call.


k8s.get

Fetches a single resource.

Input

FieldTypeRequiredDefaultDescription
connectionobjectyesCluster connection
api_versionstrno"v1"e.g. "apps/v1"
kindstryese.g. "Deployment"
namestryesResource name
namespacestrnoconnection'sIgnored for cluster-scoped kinds
not_found_okboolnofalseReturn found: false instead of failing

Output

FieldTypeDescription
foundboolWhether the resource exists
resourceobject | nullThe full resource
api_version, kind, namestrEchoed back from the request
namespacestr | nullNamespace requested; null when cluster-scoped

Use not_found_ok: true when the question is "does this exist?" rather than "fetch this".


k8s.list

Lists resources of a kind.

Input

FieldTypeRequiredDefaultDescription
connectionobjectyesCluster connection
api_versionstrno"v1"e.g. "apps/v1"
kindstryesKind to list
namespacestrnoconnection's
all_namespacesboolnofalseList cluster-wide instead
label_selectorstrnonulle.g. "app=api,tier!=canary"
field_selectorstrnonulle.g. "status.phase=Running"
limitintno500Page size, capped at 2000
continue_tokenstrnonullFrom a previous call's output

Output

FieldTypeDescription
itemslistMatched resources
item_countintItems in this page
continue_tokenstr | nullPass back to fetch the next page; null when complete
resource_versionstr | nullresourceVersion of the list
Every item lands in workflow history

A pod list in a busy namespace is megabytes, copied into history and replayed on every workflow task. Narrow with selectors and keep limit small; project what you need with output_data.


k8s.delete

Deletes a resource by name, or a set of them by label selector.

Input

FieldTypeRequiredDefaultDescription
connectionobjectyesCluster connection
api_versionstrno"v1"e.g. "apps/v1"
kindstryesKind to delete
namestrone-ofExclusive with label_selector
label_selectorstrone-ofRequires allow_bulk_delete
namespacestrnoconnection's
allow_bulk_deleteboolnofalseRequired for a selector delete
allow_cluster_scopedboolnofalseRequired for a cluster-scoped kind
allow_system_namespacesboolnofalsePermit kube-system and friends
grace_period_secondsintnonull0 forces immediate deletion
propagation_policystrno"Background"Foreground, Background or Orphan
not_found_okboolnotrueTreat an absent resource as success

Output

FieldTypeDescription
deletedboolWhether anything was deleted
deleted_countintNumber of resources deleted
nameslist[str]Names of the deleted resources
not_foundboolTrue when the target was already absent

not_found_ok defaults to true here (unlike k8s.get) — deleting something already gone is what makes a cleanup step safe to re-run. Default policy is max_attempts: 1.


k8s.scale

Sets the replica count on a Deployment, StatefulSet or ReplicaSet through the scale subresource, and reports what the count was before.

Input

FieldTypeRequiredDefaultDescription
connectionobjectyesCluster connection
kindstrno"Deployment"Deployment, StatefulSet or ReplicaSet
namestryesWorkload name
namespacestrnoconnection's
replicasintyesDesired count, >= 0

Output

FieldTypeDescription
kind, name, namespacestrEchoed back
previous_replicasint | nullCount before the change — lets you report the delta or detect a no-op
replicasintCount now requested

Scaling only requests the change. Follow it with k8s.wait to block until the pods are actually ready.


k8s.logs

Reads a bounded tail of a pod's log.

Input

FieldTypeRequiredDefaultDescription
connectionobjectyesCluster connection
pod_namestrone-ofExclusive with label_selector
label_selectorstrone-ofRead every matching pod
max_podsintno5Cap when using a selector
namespacestrnoconnection's
containerstrnonullDefaults to the pod's default container
tail_linesintno200Lines from the end of the log
since_secondsintnonullOnly lines newer than this
previousboolnofalseRead the previous terminated container's log
timestampsboolnofalsePrefix each line with an RFC3339 timestamp
max_bytesintno1048576Per-pod cap; the tail is kept when truncating

Output

FieldTypeDescription
podslistOne entry per pod read
pods[].pod_namestrPod the log came from
pods[].containerstr | nullContainer, when one was named
pods[].logstrThe log text
pods[].truncatedboolWhether it was cut to fit max_bytes
pods[].byte_countintSize of the returned text
pod_countintNumber of pods read

label_selector is how you reach a Job's pod, whose name the cluster generates:

- activity:
type: k8s.logs
name: collect-logs
input_data:
connection: "{{ k8s }}"
label_selector: "job-name={{ job_name }}"
tail_lines: 200
output_name: job_logs
There is no follow/streaming mode

Pod logs are a high-throughput data feed, and Moco's event relay is built for low-rate control streams. Wait for the resource with k8s.wait, then read the tail. For progress during a long run, poll k8s.logs.


k8s.wait

Polls a resource until a condition holds. This is the activity that turns "I asked for a rollout" into "the rollout is live".

Exactly one of three condition forms is required:

  1. condition — a status.conditions[].type such as Available, Ready, Complete or any custom-resource condition, matched against condition_status (default "True").
  2. jsonpath + value — a dotted path such as status.readyReplicas, compared as a string. List indices are supported: spec.containers.0.image.
  3. deleted: true — satisfied when the resource is gone.

Input

FieldTypeRequiredDefaultDescription
connectionobjectyesCluster connection
api_versionstrno"v1"e.g. "apps/v1"
kindstryesKind to wait on
namestryesResource name
namespacestrnoconnection's
conditionstrone-ofCondition type to wait for
condition_statusstrno"True"Status that condition must reach
jsonpathstrone-ofDotted path, compared against value
valuestrwith jsonpathExpected value
deletedboolone-offalseWait for the resource to disappear
fail_on_conditionslist[str]nonullCondition types that mean it can never succeed
poll_interval_secfloatno2.0Initial gap; backs off to a 15s ceiling
raise_on_timeoutboolnotrueSet false to get met: false back and branch

Output

FieldTypeDescription
metboolWhether the condition was satisfied
elapsed_secfloatWall-clock seconds spent waiting
poll_countintNumber of polls performed
observedstr | nullLast observed condition status or path value
resourceobject | nullThe resource as last seen

fail_on_conditions is what stops a failed Job from consuming the whole budget:

- activity:
type: k8s.wait
name: await-job
input_data:
connection: "{{ k8s }}"
api_version: batch/v1
kind: Job
name: "{{ job_name }}"
condition: Complete
fail_on_conditions: ["Failed"]
raise_on_timeout: false
retry_policy:
timeout_sec: 900
output_name: awaited
This activity blocks, and heartbeats while it does

Its default budget is 600s with Temporal heartbeats configured, so a worker crash mid-wait is rescheduled rather than silently lost. If the workflow has other work to do meanwhile, put it in a parallel branch or set async_mode: true on the call.

A condition that is never met raises a non-retryable error, so a genuine failure is not multiplied by max_attempts.


k8s.exec

Runs a command inside a pod's container and captures its output and exit status.

Input

FieldTypeRequiredDefaultDescription
connectionobjectyesCluster connection
pod_namestryesPod to run in
namespacestrnoconnection's
containerstrnonullDefaults to the pod's default container
commandlist[str]yesCommand and arguments. Not a string
stdinstrnonullText piped to the command's standard input
timeout_secintno60Budget for the remote command
max_output_bytesintno1048576Cap on each of stdout and stderr

Output

FieldTypeDescription
stdoutstrCaptured standard output
stderrstrCaptured standard error
exit_codeintExit status; 0 means success
truncatedboolWhether either stream was cut
- activity:
type: k8s.exec
name: check-migration-state
input_data:
connection: "{{ k8s }}"
pod_name: "{{ pod }}"
command: ["/bin/sh", "-c", "psql -tAc 'select count(*) from schema_migrations'"]
output_name: migration_check
k8s.exec is arbitrary remote code execution

It is the cluster equivalent of shell.run, and should be governed by the exec action on internal.k8s_activities. Unlike shell.run it defaults to max_attempts: 1, because an arbitrary command is not idempotent.

command is a list and is never passed through a shell. To use one, say so explicitly: ["/bin/sh", "-c", "..."].


Why there is no k8s.job_run

Running a batch Job is the most common reason a workflow talks to a cluster, so a single create-wait-logs-cleanup activity is a natural thing to want. It is not offered, because the four phases have four different retry postures and one activity manifest carries only one retry policy. Fused together, an API server blip while fetching logs would fail the activity even though the Job had already succeeded — and the workflow could not tell the difference.

Composed in a wfspec, each step retries on its own terms, you can see which phase failed, and "clean up on success but keep the evidence on failure" is a single condition:. The full worked example is moco-examples/k8s-demo/src/k8s-job-run.yaml:

- activity: {type: k8s.apply, name: create-job, ...}
- activity: {type: k8s.wait, name: await-job, ...}
- activity: {type: k8s.logs, name: collect-logs, ...}
- activity:
type: k8s.delete
name: cleanup-job
condition: "{{ cleanup and awaited['met'] }}"
input_data: {...}