Skip to content

Beta to main - #235

Merged
fedemaleh merged 69 commits into
mainfrom
beta
Sep 2, 2026
Merged

Beta to main#235
fedemaleh merged 69 commits into
mainfrom
beta

Conversation

@fedemaleh

Copy link
Copy Markdown
Collaborator

No description provided.

andres-nullplatform and others added 30 commits August 13, 2026 16:41
…h-annotations

fix(k8s): stop hardcoding the CloudWatch logging annotations - Beta
* fix(k8s-logs): honor the requested end time so agent log queries stop returning today's lines

* docs(changelog): record the k8s log end-time fix

* test(k8s-logs): cover the log time-range contract and reject non-numeric bounds

* perf(k8s-logs): stop reading a pod's stream once it passes the requested end time

* fix(k8s-logs): reject a window bound that is not RFC3339 instead of ignoring it

* docs(changelog): cover bound validation and the early stop

* fix(k8s-logs): keep an instance's cursor when it contributes nothing to a log page

The pagination token was regenerated from the entries of the current page alone, so an
instance with no new lines — or whose remaining lines fall past end_time — lost its
position. determineSinceTime then fell back to start_time on the next page and the
instance re-read the window from the beginning, re-delivering lines the caller had
already seen. With more than one instance the pages take turns evicting each other and
pagination never reaches the end of the range.

GenerateToken now carries the incoming cursors forward. An empty page still yields an
empty token: that is how the caller learns there are no more pages, so the cursors are
deliberately not carried across it.

The ordering, the limit cut and the token move into pagination.Page. The token records
the newest entry kept per instance, and that is the cut point only because the entries
are sorted ascending and the cut keeps the oldest ones — keeping the three steps in one
function documents that coupling and makes it testable outside package main.

Linux binaries rebuilt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(k8s-logs): cover the log pagination contract end to end

The multi-page test walks a window page by page through the production functions —
determineSinceTime, getLastReadTime, ProcessLinesFromChannel and pagination.Page — and
fakes only the Kubernetes stream, which is the one thing that cannot run here. It asserts
that paging terminates, that every line in the window is delivered exactly once and in
order, and that a line past end_time is never delivered. One of its instances has no
lines inside the window at all: that instance never earns a cursor and is re-read on every
page, which is inherent to a timestamp cursor and cheap because the processor stops at its
first line past the window.

Both tests fail against the previous behavior — the multi-page one never terminates and
delivers some lines five times.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(changelog): fold the log time range entries into one

The branch had accumulated one entry per commit for what a reader sees as a single fix:
the range you select is the range you get. They collapse into one entry that names the
symptoms first and the resulting behavior second.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* build(k8s-logs): rebuild the shipped binary on a current Go and dependencies

govulncheck reported 40 vulnerabilities in the committed binary — 31 from the standard
library, 8 from golang.org/x/net, 1 from golang.org/x/text — of which 17 are reachable
from this code: TLS handshake and x509 chain-building issues on the API server
connection, net/url parsing, and an infinite loop in x/text. The x/net/html findings that
Dependabot flags for this module are present but not reachable, since the binary never
parses HTML.

The standard library is linked into the binary, so the binary carries the standard library
of whatever compiler built it, and the go directive was a floor low enough that a machine
ten patch releases behind satisfied it. Raising the directive to the version that fixes
these makes the go command fetch a matching toolchain, so a rebuild cannot quietly ship an
older standard library again. x/net and x/text move to their fixed versions; x/sys and
x/term follow as their requirements.

Verified: govulncheck reports no vulnerabilities in source mode and against all three
rebuilt binaries, and go vet, go test and the bats suite are clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci: pin the Go version to the one the log module requires

The loose '1.25' let setup-go keep the Go that ships with the runner image, which is
behind the version go.mod now requires, and setup-go pins GOTOOLCHAIN=local so the go
command is not allowed to fetch the one it needs: `go vet ./...` failed with "go.mod
requires go >= 1.25.13 (running go 1.25.12; GOTOOLCHAIN=local)".

Pinning the exact version keeps the checks on the same toolchain the shipped binaries are
built with, which is the point of raising the directive in the first place. The shared
workflow cannot read this module's go.mod instead — its setup step hardcodes go.mod at the
repository root, and this module lives in a subdirectory — so the version lives in two
places and has to move in both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(changelog): state the log range fix functionally, in two lines

The entry had grown into a paragraph about how the fix works. This file is the source for
the customer release notes, so it should say what changed for the user and nothing about
the mechanism. The bound-validation line is dropped: an error message for an input the
console does not let you type is not release-note material.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(k8s-logs): cut the commentary down to what the code cannot say

Review asked for it: most of these restated the code or narrated a decision, and this is a
public repository. What is left is one or two lines each, kept only where a reader would
otherwise have to reconstruct something — why the upper bound is applied client-side, why
the stream read breaks instead of continuing, what previous is for in the token, why the
producer needs cancelling, and in bash the numeric guard before bc and the SECONDS rename.

The comment above the bound validation went entirely: it described what would happen
without the check while sitting next to the exit(1) that prevents it, which reads as a
contradiction.

The bats banner comments stay as they are, they match every other test file in the repo.

Binaries rebuilt: dropping lines moves the line numbers recorded in the binary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* ci: drop the commentary from the Go version pin

Review asked for it. The version and the file it has to match are visible in the diff.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Ignacio Boudgouste <ignacio.boudgouste@nullplatform.io>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
… artifacts

Manually-verified publish-images workflow (v* tag trigger) that builds each scope
worker image via the org reusable ECR workflow and registers it as an oci_image
platform artifact (visible-to organization=*), fanned out over the 3 images:
containers (k8s base), scheduled-task, containers-datadog (metric overlay).

Uses the existing NP_API_KEY secret. Rebased onto beta so the PR carries only the
feature (no main-only drift such as the cloudwatch deployment annotations).

CHANGELOG: Publish containers and scheduled task scopes as docker images.
ci: publish scope worker images + register artifacts (manual trigger)
Tags produced ECR images and registered artifacts, but no GitHub
release — the published digests were invisible to consumers. Add a
finalize job that upserts a release for the tag whose body carries an
Artifacts table for all three images (containers, scheduled-task,
containers-datadog): tag, digest, and the copyable pinned
image@digest reference. Idempotent: an existing release only gains
the table once, and re-runs with the same digests no-op.

Same information the chained release pipeline writes in scopes-lambda
(actions-nullplatform release-publish-oci); hand-rolled here because
this repo fans out three images with an ordering dependency and its
tags are human-pushed rather than cut by release-please.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ci: publish GitHub release with artifact metadata on every tag
…ntainer-orchestration provider

Adds a provider-based layer for the traffic-manager sidecar version,
mirroring the pattern already used by main_traffic_manager_port. The
container-orchestration provider's traffic_manager.version now sets
the default image tag for non-websocket scopes, while web_sockets
scopes keep forcing websocket2 regardless of the provider.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…n-provider

feat(k8s): allow cluster-wide traffic-manager version override via container-orchestration provider
The platform now nests each custom-scope workflow inside its provisioning
run (SWM injects the trace carrier into the action parameters; the np CLI
adopts it and traces one step per workflow fragment). What was missing is
the WHY: a failed step read as "exited with status 1" while the real
reason scrolled past in the log.

One seam fixes that structurally: `log error` — every one of its 700+ call
sites across the k8s scripts now also lands the message as a tracing.error
facet ON the step it happened in, via the vendored shell SDK adopting
NP_TRACE at call time. A silent failure (no log error on the way down) is
covered by an ERR/EXIT trap pair that reports the failing command itself.
Failures in a custom scope now read exactly like native ones: which step,
and what went wrong.

The two long waits (ALB active, deployment active) also mark their step
`waiting` with live progress labels, flushed per beat with a hard 2s bound
so a down tracing API costs at most ~6% of a wait's poll budget and never
extends its wall-clock timeout.

All of it is best-effort and inert by default: no nptrace.sh, no
NP_API_KEY, or no NP_TRACE means plain logging, byte-identical to before
(asserted byte-for-byte in tests). Heartbeat call sites are guarded so
overrides reusing these scripts without k8s/logging stay clean.
…ait loop

Phases inside a script can now be their own step in the trace:
np_scope_step_begin/end/timeout open a keyed sub-step under the platform
step, log errors and heartbeats attach to the innermost open node, a
sub-step still open when the shell dies inherits the shell's outcome, and
a wait that hits its deadline closes as timed_out rather than failed.

Every wait loop is instrumented — wait_for_alb and wait_deployment_active
upgraded from bare heartbeats to sub-steps (rollout heartbeats now carry
desired/launched/ready/available/updated replica counts as labels), and
wait_on_balancer, verify_ingress_reconciliation and
verify_http_route_reconciliation gain sub-steps and heartbeats.

scheduled_task/logging picks up the same tracing hooks, so scheduled-task
scopes trace like k8s ones. The blue-green switch needs no iteration
steps: it is one-shot, and switch_traffic.yaml's named steps already are
the plan.
Custom scopes now report the same data lineage the scope-workflow-manager
does, by the same canonical dataset ids so the graphs join by value:

  • apply_templates turns kubectl apply output into produces edges for the
    kinds the platform lineage model knows (k8s-deployment, k8s-service,
    k8s-ingress) — one chokepoint covers every workflow
  • the namespace create records k8s-namespace:<ns>
  • manage_dns CREATE records dns-record:<fqdn>
  • wait_for_alb records the load-balancer:<arn> dependency
  • wait_deployment_active records the cross-flow join back to the build
    (consumes <asset.type>:<asset.url>), produces this deploy's log dataset
    with the concrete log query as its pointer, declares the deploy-log
    affordance the UI renders as "view logs", and reports rollout progress
    ({current, target, unit: instances}) per heartbeat

Vendored nptrace.sh rebuilt from catalog-tracing-sh#6, which adds
np_trace_produces/consumes/affordances/progress to the shell SDK.
…r custom scopes

The workflows declare each step's trace identity (np CLI trace: blocks)
using the exact step keys, titles and plan groups the platform's own
scopes emit — create-namespace, create-ingress, wait-for-ingress,
create-dns for the scope provision; wait-for-instances (group
waiting-instances, where the dashboard attaches the instance pips) and
traffic-switch (group switching-traffic) for deployments. Step NAMES are
untouched: customer overrides anchor on them.

The actual create-* deploy steps are emitted per applied manifest by
apply_templates as keyed sub-steps (deployment-*.yaml → create-deployment,
secret-* → create-secret, scaling-* → create-hpa, service-* →
create-service, pdb-* → create-pod-disruption-budget, ingress-* →
configure-ingress/create-ingress) — the custom flow renders then
bulk-applies, so this is where those resources really get created.

Plumbing (load logging, assume role, renders, context builds) is
trace: false; route53-only stations carry flavors: [route53] so an AKS or
external_dns scope never declares a station it cannot reach; and scripts
whose branch isn't taken declare np_step_skip so their step closes
skipped, never a hollow completed.

Requires nullplatform/cli#216 for the trace: blocks and np_step_skip;
both degrade to exactly today's behavior on an older CLI (unknown YAML
keys are ignored; np_step_skip is guarded).
K8S_FLAVOR names the cluster variant (eks in the base values, aks and aro
in the overlays) — an independent dimension from DNS_TYPE, so the
workflows declare both as trace flavor tokens
(flavors: ["$K8S_FLAVOR", "$DNS_TYPE"]) and each step gates on the
dimension it genuinely depends on.
…t even as skipped

The trace is for users: verify_networking_reconciliation has nothing to
dispatch on azure/aro, so it is flavor-gated out of those plans entirely,
like the ALB stations. skipped stays reserved for declared steps whose
branch genuinely may or may not run on that flavor (a namespace that
already exists).
k8s-scope-create, k8s-deployment-initial and k8s-deployment-switch-traffic
carry their plans as previewable job definitions (version derived from the
plan hash per flavor), and each run links instance_of — the same
plan_source: job model the platform's own scopes use.
…plains, traffic set

wait_deployment_active now reports everything the platform's own
wait-for-instances does, from the data the script genuinely observes: the
'instances' counted io (healthy/launched/desired, plus per-pod restart
history from the pods' containerStatuses), the instances-health affordance
meter with unhealthy counts and probe reasons, and a per-heartbeat explain
at the operator's severity — warn while probes fail or restarts accumulate
('All 3 instances healthy — after 4 restarts' keeps a healed crash-loop
visible), plain while booting, error with structured details when it gives
up.

The blue/green switch reports the same set the platform's tracedTrafficSwitch
emits — labels, the from→to narrative, the traffic-switch affordance with
the resulting split, the request as input, what landed as output, and the
convergence toward 100% — from apply_templates, gated explicitly by
TRACE_TRAFFIC_SWITCH on the switch_traffic step.

Vendored nptrace.sh rebuilt from catalog-tracing-sh#7 (inline io +
error --details).
…lavor, the run's own reason

Every workflow now declares its trace identity, not just the three
lifecycle ones: blue_green overrides the job it would otherwise inherit
from initial (k8s-deployment-blue-green, with its own groups), update
overrides create's (k8s-scope-update), finalize/rollback/delete and the
scheduled-task trigger get their own jobs, finalize's rollout wait is
keyed wait-for-instances, and the plumbing steps (load logging, assume
role, deployment build context) are trace: false across all twenty-odd
workflow files.

A fatal exit now mirrors the REAL reason onto the RUN as well as the step
— the last log error's message, not the exit mechanism — so list views
name the cause without drilling into steps. A run-level NP_TRACE (no step
segment) mirrors nowhere extra.
…y theirs

Verified by executing the failure stories end to end: a Forbidden apply
previously traced as a bare 'Failed to apply' — kubectl's stderr (the
principal, the resource, the namespace) never reached the trace. The
capture now includes stderr, and the failure names the manifest and the
server's reason. Restart narratives gain their cause the same way:
'4 restarts so far (OOMKilled)' instead of just the count, with
restart_reasons on the instances-health meter.
…he app's own

Verified by executing the failure stories: an ImagePullBackOff now traces
the registry's verbatim message ('Back-off pulling image …: manifest
unknown') live within one poll and cause-first at the terminal; a
crash-loop attaches the previous container's last log lines and leads with
the app's own final line ('FATAL: bind: address already in use'). The
classification is live POD STATE (waiting reasons and messages), so
problems that predate the wait or throttle their events are still named —
the event sweep only fills in what state cannot say (which probe, which
path). Boot churn is never presented as a problem, and the narrative
re-evaluates every poll, emitting only when the situation changes.

The 'possible causes / how to fix' bursts no longer shadow the cause:
facets fold last-writer-wins, so recording each hint line as the error
left the LAST HINT as the step's message. The burst's first message is now
the error everywhere (step, run mirror, terminal — one cause-first message,
mechanism in parentheses) and the hints ride as structured details.hints.
End-to-end verification (real CLI executing the real create workflow against
a wire sink) caught scripts that capture the provider's error output and then
log a generic message: build_service_account, create_role (OIDC, account id,
create-role, policy attach), delete_role and require_resource. Each now
carries the captured reason on the traced error, apply_templates-style.

Trace error messages also drop the console decoration (indentation, the
marker emoji) so the cause reads clean on the trace while console output
stays exactly as it was.
The tracing SDK now rides as a submodule pinned to the SDK's release
history instead of a hand-vendored copy of nptrace.sh. The logging hooks
look for the submodule first and still fall back to a root-vendored
nptrace.sh, so repo copies that lose the submodule (archive downloads,
docker build contexts) keep working — and with neither present the
scripts degrade to plain logging exactly as before. CI checks out
submodules so the trace tests exercise the real SDK.
…p_error

When the engine's preamble publishes np_step_error, the first log error
cause of a step is stated through it, so the engine's own failure
terminal carries the real diagnosis instead of a bare exit status — one
message on the wire no matter which side reports last. The exit trap's
synthetic mechanism message never states: it must not outrank the
engine's own evidence (the shell's last stderr words).
A build that loses the tracing submodule (archive download, docker build
context) used to degrade to plain logging in SILENCE — the CLI side traces,
the structure looks fine, and the scope-side story (sub-steps, live waits,
real errors, lineage, affordances) is just absent, with nothing anywhere
saying why. Exactly the failure nobody sees. Now, when the engine is
tracing the run (NP_TRACE) and credentials are present (NP_API_KEY) but
nptrace.sh is not in the bundle, the workflow log carries one loud warning
naming the missing file. Untraced runs stay silent as before.
Validated against real blue/green deployments on a custom scope, side by
side with the native experience:

- finalize declares its own group — its steps used to file under
  Setting up / Waiting for instances, twins of the deploy workflow's
  stations, and the ending never got a station of its own. The entity
  ending overlay names it (Finalized / Rolled back).
- rollback likewise: one Finalize group — a rollback's wrap-up is the
  ending station's story.
- switch_traffic scopes ALL its steps under Switching traffic:
  per-increment runs fold there as attempts (the native reading)
  instead of re-lighting Setting up / Waiting on every increment.
- the wait steps get workflow-distinct keys (finalize-instances-check,
  switch-instances-check): three workflows declared the same
  wait-for-instances key, so the fold merged semantically different
  waits into one row across runs.
Every step a user sees carries a clear title in the native tone
(Validate load balancer capacity, Promote new deployment, Restore
traffic routing, Remove previous deployment); build/template plumbing
is trace: false. rollback gains its full trace pass (it had none).
Steps injected by the overrides repo keep their keys — the dashboard
humanizes an untitled key rather than showing snake_case.
… no submodules

The agent materializes the package source with a plain clone, so the
catalog-tracing-sh submodule arrives empty and every run logged
'tracing SDK not bundled' (observed live: zero step io on three
deployments that declared it). The logging loader already prefers the
submodule and falls back to a root copy — ship the copy. Kept in sync
by the existing re-vendor chore.

Also: blue_green/initial declare placeholder groups (switching-traffic,
finalize) so the whole journey renders pending from the first paint —
paired with the CLI emitting an optional placeholder step per empty
declared group.
pablovilas and others added 28 commits August 29, 2026 21:48
io lineage edges (image consumed, logs/ingress/workload produced) were
silently dead-lettering: the binding carried the io descriptor's
kind/uri, which the API's tracing.binding schema rejects. The bumped
SDK sends {name} on the edge and keeps the full descriptor on the node.
np_scope_wait_heartbeat filed per-poll diagnostics (wait.what/ready/
state/desired/..., including an unresolved-config literal) as LABELS,
which the dialog renders as tag chips — machine noise on a human
surface. What a phase waits on is the tracing.signal facet: name +
direction + deadline; the counts already ride the meter and progress
facets, elapsed rides the node's own clock.
The io names said 'workload' — a k8s category, not a manifest kind.
The dialog now names what kubectl actually reported: deployment (and
removed-deployment on deletes); the dataset ids were always exact
(k8s-deployment:<ns>/<name>).
Every workflow's trace.job carries namespace: @context:scope.provider —
the scope's service specification. Two providers shipping the same
workflow name with an identical plan hash previously converged on ONE
job definition (namespace 'workflows') and would fight over its
identity labels; per-provider namespaces make each scope type own its
jobs by construction.
The page titled these runs by humanizing `labels.workflow` — this file's basename —
which meant the name on screen was whatever the file happened to be called. `trace.title`
says it outright, landing on the run's explain.title where every consumer looks first.
…ow stand

Four assertions still described the behaviour these changes replaced, so the branch
was red on work that is correct:

- a wait states `tracing.signal` (name, direction, deadline in ms), not `wait.*`
  labels — the labels were rendering as code-y chips on the phase;
- an edge's binding names WHICH io the edge is about and nothing more (sending the
  whole descriptor is what dead-lettered every lineage edge);
- progress units are the closed set, so `count` stays `count`.

The extra-k=v-labels case is replaced by its inverse — a guard that no wait
bookkeeping reaches the labels — and a wait whose timeout never resolved is now
covered too.
catalog-tracing-sh#10 is merged, and its branch is gone. The submodule pointed at a
commit that now only exists inside main's history — the vendored content is identical,
but .gitmodules declares `branch = main`, so the pointer should be main's tip.
… stop advertising work they cannot do

'Promote new deployment' (and rollback's 'Restore previous deployment') run
scale_deployments, which is a no-op for every strategy but rolling — so on a
blue-green scope they were permanently grey rows nobody could explain.

The scope's deploy strategy becomes a flavor DIMENSION, read from the same
context path the scripts read, and those steps gate on `rolling`. One job still,
two plan versions — which is exactly what hashing the effective plan is for.

Every remaining finalize/rollback step now carries a one-line description, so the
checklist reads without prior knowledge of the workflow.
The console hints already classify a failing wait — 'The application did not pass its
health check at /health-bad. Detected: Startup probe — app responded with HTTP 404
(expected 2xx).' plus a targeted fix — but that never reached the trace, so a reader who
opened the phase saw counts and a timeout and had to go to the logs for the reason.

The same classifier now feeds the step's explain: WHY on the reason, NEXT on the fix.
It rides the LIVE narrative too, not just the give-up — the reader asks 'why?' the moment
the meter goes amber, not ten minutes later.
The phase read '1 with Startup, probe, failing'. The reason list splits on the comma —
but IFS was ', ', so it split on whitespace too, and the event sweep's already-human
reasons ('Startup probe failing') were shredded into three codes that matched nothing
and were echoed verbatim.

Split on the comma alone, trimming the space that follows it. Codes still translate and
still dedupe after translation.
A container that is OOM-killed reports two things: `state.waiting.reason`
CrashLoopBackOff — the mechanism that keeps restarting it — and
`lastState.terminated.reason` OOMKilled, why it actually died. Both the
classifier and the wait narrative read the waiting reason first, so every
surface named the symptom:

  Waiting for 0/1 instances to be healthy — 1 with crashing repeatedly:
  back-off 20s restarting failed container=application
  pod=d-253247585-630235960-dd685c5-trn9s_nullplatform(498dc0ab-…)

  Reason: The container started and crashed repeatedly.
  Next:   Review application logs for startup errors (…, panics).

That sends the reader hunting for a bug in their startup path when the
answer is the memory limit — and the OOM branch the classifier already
carries could never fire. The OOMKilled fact was on the step all along,
three panels down under "Restarted → Reason".

Now the backoff wrappers (CrashLoopBackOff, BackOff) defer to the
termination reason; every other waiting reason IS a cause and still wins,
so ImagePullBackOff is unaffected. The line becomes:

  Waiting for 0/1 instances to be healthy — 1 with out of memory

  Reason: The container exceeded its memory limit (256Mi) and was terminated.
  Next:   Increase ram_memory for scope 'Stage' or reduce memory usage.

The back-off message is dropped from the narrative — it restates the reason
and then buries the line in two ids nobody reads — and what remains is
capped to a line. Neither is lost: the verbatim message, the CrashLoopBackOff
wrapper (as `reason`) and the new `cause` all stay on the io, so an agent
still sees the loop, the ids and the untruncated text.
Auditing the other warn/failure paths after the OOM fix turned up three
gaps, one of them introduced by that fix:

- `Error` had no branch. Deferring the backoff wrapper to the termination
  reason sends the ORDINARY crash loop — a panic, a bad config, a missing
  dependency, all of which terminate as `Error` — to the nameless default:
  "Pods are failing with reason: Error", with no suggested fix at all. That
  is worse than the generic wording it replaced. `Error` now carries the
  crash-loop advice plus its exit code, the first thing anyone greps for.

- The restart-only narrative never diagnosed. A container that crashed and
  is running again sits between waiting states, so nothing classified it,
  and the branch said only THAT it restarted — an amber phase with no cause
  and no next step. It calls the classifier like every other surface now.

- Evicted, Unschedulable and DeadlineExceeded were in the humanize table but
  had no branch, so they reached the reader named and unexplained.

The unknown-reason default keeps its EMPTY suggested fix: that emptiness is
the signal that hands the reader the full generic troubleshooting checklist,
which is richer than any line this branch could invent for a code it does
not know. Filling it in swapped that checklist for a one-liner.
`ram_memory` is the capability KEY. The scope form calls it "RAM Memory", so
"Increase ram_memory for scope 'Stage'" sent the reader looking for a field
that is not on the page — the same leak the OOM diagnosis itself was: the
right answer, spoken in a vocabulary the reader has no way to act on.

Three strings named an internal or foreign identifier:

- ram_memory -> "the RAM Memory of scope 'X'".
- initialDelaySeconds/timeoutSeconds are the KUBERNETES spellings; the scope's
  own settings are `health_check.initial_delay_seconds` / `timeout_seconds`,
  shown as Initial Delay and Timeout.
- activeDeadlineSeconds is not a scope setting at all, so it named a field the
  console does not have. Reworded to say the thing instead of the field.

Genuine Kubernetes objects (PVC, configmap, nodeSelector/affinity) keep their
Kubernetes names: those are what they are, and renaming them would make them
unfindable in the cluster the reader has to go look at.

Three tests asserted the old wording; two of them encoded a field key as the
user-facing contract, which is what let this ship.
Seven of the sixteen workflows declared no `trace:` block at all, so their runs
reached a consumer with every naming rung empty:

  run_id  : workflow-diagnose-395c6e18-5574-4b54-b39e-735541b16aa2
  explain : null      <- no declared title
  job     : null
  key     : null

The title contract then lands on the run id, and a consumer that will not print
a uuid substitutes its own neutral word: the deployment page showed the failure
diagnostics as "Activity · 21 steps · all succeeded", which names nothing the
reader can act on.

The name was in `labels.workflow: "diagnose"` the whole time — and that is
exactly the label a consumer must NOT title from: it is `filepath.Base` of this
file, private to this engine's layout, and the next producer's `workflow` label
means something else. So the fix is to say it, not to have it read.

Titled: the deployment diagnose and kill_instance workflows, and the scope
diagnose, pause/resume-autoscaling, restart-pods and set-desired-instance-count
workflows. Every workflow in the package now names itself.
Every check already decides a verdict — 10 call sites report `failed`, 3
`warning` — and builds real evidence: a summary, affected pods, details and
suggested actions. All of it went into the results file for the diagnose API
and none of it onto the trace. On the wire every one of the 21 check steps read:

  completed  sev=-  Memory-Limits            err=-  out=0
  completed  sev=-  Container-Crash-Detection err=- out=0

So the page showed "21 steps · all succeeded" directly under a deployment that
had just died of an OOM — including the two checks that found it.

`update_check_result` is the one funnel every check goes through, so the verdict
is mirrored there: the finding becomes the step's `explain.what`, its first
suggested action becomes `next`, and the evidence rides along as an output so
the dialog can show the affected pods.

The step's STATUS is deliberately left alone. A check that finds a problem did
its job; failing the step would conflate "this check broke" with "this check
found something". The finding rides `explain.severity` — error for a failed
check, warn for a warning — the same channel every other producer surface uses.
A passing or skipped check stays silent: 21 lines of green is how nothing gets
read.
Review feedback: these messages are the main output the deploying dev sees,
so a test should fail when one of them changes. Asserting fragments
("Detected: Startup probe", "not yet listening") let a message be reworded
without a single test noticing.

Every pod-diagnostic branch now asserts the complete emitted lines -- the
reason, the detected line, the details, the recent-warnings block and the
suggested fix -- so breaking any one of them is a red test. Verified by
reordering four words inside one message: test 11 fails, where the old
fragment assert passed.

Also drops the explanatory prose comments from the file; the repo is public
and the asserts now say what the tests were explaining.
…heir plans

The deploy workflows (blue_green, initial) register their jobs with identity
labels, which is how the deployment page shows the whole checklist pending the
instant an operation is requested. The lifecycle workflows (rollback, finalize,
delete, switch_traffic) registered theirs with no labels at all -- so a cancel's
rollback plan, though registered and current, was unfindable, and the page
showed an empty canvas for the ~13 seconds between the entity saying
"cancelling" and the agent picking the rollback up, then popped every step in
at once.

Same grammar as the deploy jobs: `entity` + `scope.provider` scope the search;
`operation` names the lifecycle act -- the word the deployment entity's own
statuses use (rollback, finalize, delete, switch-traffic) -- where a deploy job
uses `strategy`.
…find their plans

The deployment lifecycle jobs already register with identity labels; the scope
lifecycle jobs (create, update, delete) registered with none -- so no surface
can find a scope operation's plan before its run exists. Same grammar:
entity + operation + scope.provider.
…232)

notify_results passed the aggregated check results to jq as a single
--argjson value and then to np as a single --body value. Linux caps one
execve argument at 128 KiB, which ulimit does not lift, so once a check
began publishing application log tails the step died with "jq: Argument
list too long" and diagnose published nothing.

Keep the payload on disk end to end: xargs for the file list (already
the case), --slurpfile instead of --argjson, and a file path for
np --body. The .json suffix on the temp files is required — np reads
--body from disk only when the value ends in .json, and otherwise sends
the string itself as the request body.

Also cap log lines at 2000 characters in lines_to_json_array. kubectl's
--tail bounds how many lines we collect, not how long each one is, so a
single serialized stack trace could still dominate the payload.

Tests use a 2 MB payload so they fail on macOS too, where the limit is a
total argv size rather than a per-argument cap.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
feat(k8s): trace-instrument the workflow — every error clear, waits live
chore(tracing): drop the root SDK copy, ship the vendored submodule in images
docs(changelog): tracing for k8s scope and deployment workflows (#215)
@fedemaleh
fedemaleh merged commit 4ce13fe into main Sep 2, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants