Skip to content

yconverge kubectl passthrough + support converge-mode#3

Merged
solsson merged 9 commits intomainfrom
yconverge-kubectl-shellouts
Apr 28, 2026
Merged

yconverge kubectl passthrough + support converge-mode#3
solsson merged 9 commits intomainfrom
yconverge-kubectl-shellouts

Conversation

@solsson
Copy link
Copy Markdown
Contributor

@solsson solsson commented Apr 28, 2026

Reintroduces behavior from the bash kubectl-yconverge impl + adds dependency and checks output.

Yolean k8s-qa and others added 9 commits April 28, 2026 07:37
…gs to debug

Six zap.Info call sites moved to zap.Debug:

- yconverge.go: "converge plan", "converge dependency",
  "converge target" -- the orchestration scaffolding around what
  gets applied. With kubectl wrapping coming next they'd be
  noise on top of kubectl's own output.
- checks.go: the per-check "check" lines for wait / rollout /
  exec. The check kinds emit their own progress to the user
  (kubectl wait, kubectl rollout status, the exec command's
  stdout) so a structured pre-line is just duplication.
- k8sapply.go: the per-resource "applied" line. Same logic --
  kubectl apply already prints `<kind>/<name> <verb>` in the
  shape developers know.

`-v` / --verbose still surfaces all of these. The next two
commits switch yconverge's apply / wait / rollout to kubectl
shellouts so the default output matches what `kubectl yconverge`
users expect.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Switches yconverge's three user-visible operations off client-go
helpers and onto direct `kubectl` invocations with stdout / stderr
forwarded to the host process. The wire effect is identical
(server-side apply with --force-conflicts, --field-manager=y-cluster,
the same wait / rollout-status semantics) -- what changes is what
the developer sees:

  $ kubectl yconverge --context=local -k base
  configmap/db-config serverside-applied
  service/db serverside-applied
  deployment.apps/backend condition met

instead of the structured zap "applied / check" lines from the
client-go path.

Why: kubectl-yconverge's audience already lives in kubectl's
output shape. Wrapping the API instead of forking the binary
gave us typed errors but stripped the line shape every consumer
parses mentally for create / configured / unchanged signals.

pkg/k8sapply and pkg/k8swait stay -- they're used by
pkg/provision/envoygateway.Install where the audience is
operators reading provision logs, not developers iterating on a
kustomize tree, and the typed errors / CRD-first ordering matter
for that path's retry semantics.

pkg/yconverge/kubectl.go is the new home for the helpers. They
are private to the package; the public API of pkg/yconverge
(Run, Options, Result) is unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five label-routed kubectl invocations match the bash plugin's
order from ystack@90e8923 one-for-one. `pkg/k8sapply.Apply`'s
"every resource is server-side-force" was a regression: ystack
kustomizations annotate immutable resources (Jobs by name) with
yolean.se/converge-mode=replace, one-shots with =create, and
expect those modes' semantics. The Go binary silently overrode
all of them with SSA-force.

The five steps:

  1. yolean.se/converge-mode=create       kubectl create --save-config
                                          (tolerates AlreadyExists +
                                          empty selector match -> the
                                          documented "skip if exists")
  2. yolean.se/converge-mode=replace      kubectl delete
                                          (tolerates "No resources found"
                                          on the first run)
  3. yolean.se/converge-mode=serverside-force
                                          kubectl apply --server-side
                                                        --force-conflicts
  4. yolean.se/converge-mode=serverside   kubectl apply --server-side
  5. label!=create,!=serverside,!=serverside-force
                                          kubectl apply
                                          (replace-mode resources land
                                          here too, freshly applied
                                          after step 2's delete)

Each step's stderr is captured: tolerated substrings ("no objects
passed to apply" when a selector matches nothing, "AlreadyExists"
on create idempotency) suppress the whole invocation; anything
else is forwarded to the user's stderr before the error
propagates so the diagnostic isn't swallowed.

--dry-run=server forwards through every step, including the
delete, so a replace-mode resource's plan is provably non-
mutating end-to-end -- the bash plugin's behaviour preserved.

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

`sleep infinity` is a GNU coreutils extension. macOS BSD sleep
rejects it with "invalid time interval" -- bash then exits
before the test has a chance to send SIGTERM, and stopVM
short-circuits through the "process is already dead" branch.
The test passed by coincidence on Linux because GNU sleep
accepts the literal; on macOS the elapsed-time assertion
caught the race ("181us; SIGKILL escalation path not exercised").

`sleep 60` is portable and well past the test's 6s grace
budget.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pulls the per-step argv builder out of kubectlApply into a pure
applySteps(opts) []applyStep so the kubectl invocation each
mode produces is testable without spawning kubectl. Five tests
pin the contract:

- Order + count: the bash plugin runs five label-routed steps
  in a fixed order (create -> delete -> serverside-force ->
  serverside -> plain-apply); a refactor that reshuffles them
  fails loudly.
- Per-step argv: full slice match for non-dry-run Options. Drift
  in --save-config / --server-side / --force-conflicts /
  --field-manager=y-cluster / the negative selector formula
  shows up as a test diff rather than as silent semantic
  change against a cluster.
- Dry-run forwarding: --dry-run=server must reach every step
  including delete, so a replace-mode resource's dry-run is
  provably non-mutating end-to-end.
- No dry-run by default: an empty Options.DryRun must not
  inject --dry-run=anything.
- Tolerated stderr substrings: the documented idempotent paths
  (empty selector match, AlreadyExists on re-create) are
  swallowed; anything else surfaces. Loosening these would
  silently hide a real failure.

The runtime keeps using applySteps; the refactor is a
mechanical extraction.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three e2e tests against the shared kwok cluster, each exercising
one mode-specific contract that the previous "every resource is
SSA-force" path got wrong:

- TestConvergeMode_CreateSkipsIfExists: the regression that
  motivated the branch. label=create renders to `kubectl create
  --save-config`, which means a re-run with edited source data
  must NOT update the cluster value. Run twice with foo=original
  -> foo=changed; assert cluster keeps "original".

- TestConvergeMode_ReplaceRecreates: label=replace runs delete +
  apply, so the resource UID changes between runs even when the
  spec is byte-identical. Captures uid1 from run 1, uid2 from
  run 2; uid1 != uid2.

- TestConvergeMode_MixedKustomization: a single kustomize tree
  with all four mode buckets (create / serverside-force /
  serverside / unlabelled) lands cleanly. Doubles as the
  tolerated-stderr smoke: the four un-used mode steps must
  swallow "no objects passed to <verb>" rather than failing
  the whole run.

The output-forwarding contract (kubectl-style per-resource
lines reaching the user's stdout) is the next commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The whole motivation for switching yconverge from client-go SSA
to kubectl shellouts was so the developer running
`kubectl yconverge` sees the line shapes they're used to from
running kubectl directly. Pin that with a subprocess-based e2e
test:

- Plain-apply step prints "configmap/<name> created" on first
  run -- kubectl client-side-apply's verb.
- serverside-force step prints "configmap/<name> serverside-applied"
  -- distinct verb because the step uses --server-side.
- "No resources found" (kubectl delete's empty-match line on
  stdout, exit 0) MUST NOT reach the user; otherwise a vanilla
  yconverge run is noisy.

The empty-match assertion forced a small fix in
runKubectlTolerant: kubectl delete writes "No resources found"
to STDOUT (not stderr) with exit 0. The previous design only
suppressed stderr on tolerated substrings, so the line leaked
through. Add a parallel stdoutSuppress list and route the delete
step's empty-match handling through it. The bash plugin used the
same two-channel approach (stderr-tolerate + stdout-suppress) for
the same reason.

applyStep grows a stdoutSuppress field; argv unit tests updated
to pin both channels' contracts.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds TestConvergeMode_DependencyGetsSameApplyPath. Builds a tmp
CUE module with a base/ kustomization carrying converge-mode=replace
on its ConfigMap, imported by a dependent/ kustomization. Runs
yconverge against the dependent twice; asserts the base
ConfigMap's UID changes between runs.

If the dep path silently bypassed label routing (e.g. a future
"fast path" that just shells out a single kubectl apply per dep
step instead of the five-step plan), the UID would stay the
same on the second run and the test fails.

The wiring is shared in code (Run -> convergeSingle -> kubectlApply
-> applySteps for both deps and the final target), but until now
no test proved that identity. Reading the source is not
sufficient: a future refactor that introduces a different code
path for deps would compile and pass the existing ordering tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Four headers, written to opts.Stdout (defaults to os.Stdout) in
the same sink as forwarded kubectl output:

  yconverge dependency <relpath>     before each dep step
  yconverge converge-mode=<mode>     before each non-empty mode bucket
                                     (suppressed alongside an empty
                                     bucket's kubectl output)
  yconverge target <relpath>         before the final step, only
                                     when at least one dep ran
  yconverge check N/total <kind>     before each check, before it runs

Sample run shape:

  $ kubectl yconverge --context=local -k example-replace-dependent
  yconverge dependency yconverge/itest/example-replace
  yconverge converge-mode=replace
  job.batch "example-replace-job" deleted from default namespace
  job.batch/example-replace-job created
  yconverge target yconverge/itest/example-replace-dependent
  configmap/example-replace-dependent unchanged
  yconverge check 1/1 exec

Design choices:
- Plain text on stdout, NOT zap. These lines are part of the
  command's UI, like kubectl's `<kind>/<name> created` lines;
  zap is for diagnostics on stderr (`-v` keeps the existing
  Debug "converge plan / target" structured lines available).
  No duplication: events are emitted in exactly one channel.
- Replace-mode's two kubectl invocations (delete + recreate)
  group under one "converge-mode=replace" header. applyGroups
  encodes the structure: each group is one mode and holds
  the one or two invocations behind it. The runner emits the
  header once per group, only if at least one invocation
  produced non-suppressed output.
- Default (unlabelled) bucket gets no header -- kubectl's
  per-resource lines speak for themselves. The selector now
  also excludes replace so we don't double-apply the resources
  the replace group's second invocation already created.

CheckRunner gains a Stdout field that yconverge.convergeSingle
threads through; defaults to os.Stdout.

kubectlWait / kubectlRolloutStatus take an io.Writer arg so
their kubectl output lands on the same sink as the apply path
(otherwise check progress would race with applies on stdout).

Five unit tests in pkg/yconverge/kubectl_test.go pin the group
structure (count, mode order, replace's two invocations,
default-excludes-replace, dry-run forwarding, no-dry-run-by-
default, tolerance contract).

Three e2e tests in e2e/converge_progress_test.go run the binary
as a subprocess and assert:
- The four headers appear in expected order on a deps+target+check
  fixture.
- A no-deps run does NOT print 'yconverge target' (user already
  knows what they passed).
- An unlabelled-only run does NOT print any 'yconverge converge-mode='
  header.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@solsson solsson changed the title yconverge kubectl shellouts yconverge kubectl passthrough + support converge-mode Apr 28, 2026
@solsson solsson merged commit 33c02a1 into main Apr 28, 2026
11 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.

1 participant