fix: bound WinRM command.Wait() and remotefs upload against dead sessions - #473
Conversation
smoke-windows CI hung for the full 60-minute test timeout: a Windows host rebooted mid-uninstall, reconnected successfully, and the very next remotefs.Upload call then hung indefinitely. A goroutine dump from the test's own timeout panic traced the root cause to rig v2: command.Wait() (protocol/winrm/connection.go) and the remotefs rigrcp helper's command loop (remotefs/winfile.go) had no timeout at all, so a WinRM session that silently died post-reboot could block the caller forever. Filed and fixed upstream as k0sproject/rig#472 / k0sproject/rig#473. The upstream fix only helps if a caller actually supplies a bounded context: rig's plain Exec/ExecOutput hardcode context.Background(), which never has a deadline. Add that bound here as defense in depth, independent of when the upstream fix lands: - pkg/configurer/host.go: extend the Host interface with cmd.ContextRunner so configurers can call ExecContext/ ExecOutputContext. - pkg/product/mke/config/host.go: add matching ExecContext/ ExecOutputContext wrapper methods on Host, mirroring Exec/ ExecOutput's existing sudo/SudoDocker routing (the promoted methods from the embedded *rig.Client would silently skip that routing). - pkg/configurer/windows.go: bound every Exec/ExecOutput call in InstallMCR, UninstallMCR, and RestartMCR -- the MCR lifecycle operations that run immediately around host reboots -- with a new windowsExecTimeout (15 minutes, generous for legitimate slow installs/image pulls, not a tight SLA). remotefs.Upload calls in these functions are unaffected by this change; they are already self-bounded by the upstream winfile.go fix regardless of caller context. Points the go.mod replace directive at the fork commit carrying the upstream fix (k0sproject/rig#473) until it merges and is tagged upstream, at which point the replace should be dropped and the version bumped normally. Added TestExecCtxIsBoundedAndCancelable pinning that the new helper actually carries a deadline and that cancel works. Verified: go build, go vet, full go test --tags 'testing' ./pkg/..., golangci-lint run pkg/configurer/... pkg/product/mke/config/... (one pre-existing, unrelated gofumpt finding in hosts.go, unchanged by this commit). End-to-end verification is the smoke-windows re-run this was found on. Signed-off-by: James Nesbitt <jnesbitt@mirantis.com>
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved timeout coverage, cleanup, and payload-write blocking issues remain.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
This PR bounds WinRM command waits and remote-file control operations to prevent indefinite hangs after dead sessions.
Changes:
- Propagates contexts into WinRM command waits.
- Adds a 30-second rigrcp command timeout.
- Adds timeout, success, and ended-session tests.
File summaries
| File | Review findings |
|---|---|
remotefs/winfile.go |
Critical: payload writes can still block indefinitely. Moderate: timed-out operations may leave goroutines and the file daemon active; timeout errors lack a public sentinel. |
remotefs/winfile_internal_test.go |
Nit: add coverage for a blocked writer and its cleanup. |
protocol/winrm/connection.go |
Applies caller context deadlines to command waits. |
Review details
Suppressed comments (2)
remotefs/winfile.go:279
- On a timeout after the file daemon has been opened, this returns without canceling
f.cancelor closing the pipe. The response reader goroutine above (and a writer goroutine when the write itself is the operation that timed out) can therefore remain parked forever, while the WinRM command was started with a cancellable-but-not-deadlined context. Cancel/tear down the daemon on this failure so the per-command goroutines are released and the file is not left half-open.
case <-ctx.Done():
return nil, f.pathErr(OpOpen, fmt.Errorf("%w: writing rcp command %q", errRcpTimeout, cmd))
remotefs/winfile_internal_test.go:29
- This test uses
io.Discardforstdin, sofmt.Fprintfcan never block and the newly added write-timeout path (and its blocked-writer cleanup) is untested; it only covers waiting for a response. Add a separate case with an unconsumedio.Pipewriter or another blocking writer, while retaining this response-timeout case.
stdin: nopWriteCloser{io.Discard},
stdout: bufio.NewReader(stdoutR),
- Files reviewed: 3/3 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
b534cbe to
18ad7ce
Compare
There was a problem hiding this comment.
🟡 Changes recommended
WinRM abandonment can still block or leak resources, and timeout handling and test timing need correction.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (2)
remotefs/winfile.go:352
timedOuthard-codesOpOpen, butcommandis also used byRead(which returns this error directly). A read that hits the new timeout therefore exposes anfs.PathErrorwhoseOpisopen, contradicting the filesystem convention that the operation names the public call (remotefs/patherror_test.go:10-19). Return the timeout cause here and let each public operation add its own path error, asReadand the other callers require.
func (f *winFile) timedOut(what string) error {
err := fmt.Errorf("%w: %s", ErrTimeout, what)
f.abort(err)
return f.pathErr(OpOpen, err)
remotefs/winfile_internal_test.go:213
- This test sleeps for exactly half of the 100 ms idle timeout before each chunk, so scheduler/GC jitter can make the gap between progress reports reach the timeout and fire the watchdog before the next read. With three chunks the test is already intended to outlast the timeout; use more chunks (or otherwise leave a margin per interval) while keeping the total transfer longer than
timeout.
timeout = 100 * time.Millisecond
chunks = 3
- Files reviewed: 5/5 changed files
- Comments generated: 2
- Review effort level: Lite
18ad7ce to
1d0b723
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved writer-lifetime, cleanup, timeout-race, and buffer-handling issues remain.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (3)
protocol/winrm/connection.go:365
- This changes
Wait()so it can return while the remote command and its copying goroutines are still running, but the exportedStartProcessdocumentation below still says that itsWait()blocks until the command finishes. Update the public contract (and theWaiterdocumentation if applicable) to explain the early context return and the resulting writer-lifetime requirement.
// Wait blocks until the command finishes or ctx (the context StartProcess was
// called with) is done, whichever happens first.
//
// Without this, a command whose underlying WinRM connection has silently
// died -- for example the host rebooted mid-command and this shell was
// never actually torn down on this end -- blocks forever: neither
// c.wg.Wait() nor the underlying masterzen/winrm Command.Wait() it calls
// take a context or have any internal timeout of their own. If ctx has no
// deadline this preserves the previous unbounded behaviour; callers that
// want a bound must set one on the context passed to StartProcess.
protocol/winrm/connection.go:352
- The two cleanup requests are issued serially even though this path is specifically for an unresponsive host. If
c.cmd.Close()blocks on the dead WinRM session,c.sh.Close()is never attempted, leaving the shell cleanup behind indefinitely. Run the cleanup requests independently or give each its own bounded cancellation path.
go func() {
_ = c.cmd.Close()
_ = c.sh.Close()
}()
remotefs/winfile.go:120
Timer.Stopdoes not wait for anAfterFunccallback that has already started, so the callback can still callabortafter the transfer returns successfully. If expiry races with the final read/write, this marks the file closed and closes its pipes, causing a subsequent operation to fail withfs.ErrCloseddespite the completed operation. Synchronize callback execution withstop(or use an active/generation check) so an operation cannot be aborted after it has ended.
func (w *watchdog) stop() {
w.timer.Stop()
}
- Files reviewed: 6/6 changed files
- Comments generated: 2
- Review effort level: Lite
1d0b723 to
a7862eb
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved critical and moderate findings affect timeout coverage and concurrency safety.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (3)
protocol/winrm/connection.go:351
- On the context-error path,
abandonletsWaitreturn while the stdout/stderr copy goroutines can still be inside the writers.waiterWrapper.Waitthen immediately closes itstraceClosers(for example,redactWriter/iostream.ScanWriter), anddetachcannot prevent an already-started write. Those writers are not concurrency-safe, so cancellation with output logging enabled can race their buffers/closed flags or block while the copy is still using them. Owned closers need to be shut down only after the copy goroutines have exited, or made safe for this lifetime.
func (c *command) abandon() error {
c.stdout.detach()
c.stderr.detach()
go func() { _ = c.cmd.Close() }()
go func() { _ = c.sh.Close() }()
protocol/winrm/connection.go:419
- The context-error path can also leave the goroutine copying
stdinrunning:ctxReaderonly notices cancellation before the next read, and a read already in progress may continue afterWaitreturns. The contract warns callers not to reuse the output writers but says nothing about reusing or closing the input reader, so callers can still race that goroutine; document the same lifetime restriction forstdin(or make the input side part of the teardown guarantee).
// that recycles or concurrently reads the writers it passed in must therefore
// not do so when Wait reports a context error.
remotefs/winfile.go:215
f.stdinis theio.PipeWritercreated inopen, whoseWritedoes not return until the entire slice has been consumed. Thusdog.progress()is not called while a 32 KiB chunk is transferring: a steadily progressing connection slower than about 1 KiB/s can spend over 30 seconds in oneWriteand be aborted as stalled, contrary to the documented idle-timeout behavior. The watchdog needs to observe partial consumption rather than only chunk completion.
dog := f.watch(fmt.Sprintf("writing %d byte payload", len(p)))
defer dog.stop()
written := 0
for written < len(p) {
n, err := f.stdin.Write(p[written:min(written+payloadChunkSize, len(p))])
- Files reviewed: 7/7 changed files
- Comments generated: 2
- Review effort level: Lite
a7862eb to
b566771
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Two unresolved moderate findings require correction before approval.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (1)
remotefs/winfile.go:511
- If the final
qround-trip times out,commandaborts the session and returnsErrTimeout, but this code logs that error and still returns nil. In the upload path,remote.Close()can therefore report success and the caller proceeds toSha256on a dead WinRM session, potentially re-entering an unbounded background command instead of seeing the timeout. Propagate theqerror after marking/closing the file.
_, err = f.command("q")
log.Trace(context.Background(), "rigrcp quit", log.ErrorAttr(err))
f.stdin.Close()
f.closed.Store(true)
- Files reviewed: 7/7 changed files
- Comments generated: 1
- Review effort level: Lite
b566771 to
8da4416
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Cleanup can still block on writer closure, and abandoned-command teardown may accumulate goroutines.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (1)
protocol/winrm/connection.go:351
- Each abandoned command starts two teardown goroutines, and the comments explicitly allow either
Closecall to never return. Repeated context-bounded commands against a dead host therefore permanently accumulateWait/close goroutines and their captured shell/command state, eventually exhausting process resources even though callers no longer hang. Please add connection-level tracking or a bounded/deduplicated teardown strategy before treating this as a general fix.
func (c *command) abandon() error {
c.stdout.detach()
c.stderr.detach()
go func() { _ = c.cmd.Close() }()
go func() { _ = c.sh.Close() }()
- Files reviewed: 9/9 changed files
- Comments generated: 2
- Review effort level: Lite
8da4416 to
83294fd
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Add coverage for command.Wait cancellation paths and correct the misleading writer-lifetime comment.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (1)
protocol/winrm/connection.go:286
- This new internal comment says the caller is entitled to reuse its buffers as soon as
Waitreturns, but theStartProcesscontract below explicitly forbids recycling, closing, or concurrently using those writers whenWaitreturns a context error. Sincedetachcannot cover a write that already passed the atomic check, the sentence is misleading and should describe the caller's required lifetime instead.
// those goroutines outlive Wait, while a caller is entitled to reuse the
// buffers it passed in as soon as Wait returns.
- Files reviewed: 9/9 changed files
- Comments generated: 1
- Review effort level: Lite
83294fd to
b168e2f
Compare
b168e2f to
4840941
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Critical issues remain with unbounded upload cleanup and incompatible chunked payload handling.
Get a fresh assessment by requesting another Copilot review.
Review details
- Files reviewed: 9/9 changed files
- Comments generated: 2
- Review effort level: Lite
4840941 to
0bffd76
Compare
There was a problem hiding this comment.
🔵 Needs a closer look
The positive transfer test concurrently reads one pipe through separate readers, making it flaky and potentially incorrect.
Review details
Suppressed comments (1)
remotefs/winfile_internal_test.go:254
- This test starts
serve's reader ons.commands, then drains payload bytes from the separates.requestsreader, but both fields wrap the samestdinRpipe. TheserveAndDrainhelper is likewise concurrently readings.commands, so payload bytes can be consumed and discarded by one reader while the test waits on the other, making this positive transfer test flaky or falsely exercising a different stream than the real helper. Consume the command and subsequent payload from one reader (the helper's single stdin stream) instead of having concurrent readers.
<-served // s.requests is serve's until it has read the command
var total int64
for range chunks {
time.Sleep(timeout / 4)
n, err := io.CopyN(io.Discard, s.requests, payloadChunkSize)
total += n
- Files reviewed: 12/12 changed files
- Comments generated: 0 new
- Review effort level: Lite
0bffd76 to
bff41fb
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved timeout propagation and error-handling issues must be addressed before approval.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (2)
remotefs/upload.go:109
- The timeout guard only sees the error returned by
copyAndVerifyUpload, but that helper discardsremote.Close()errors whenCopyFromalready failed. If the source fails and the subsequent close hits the new session timeout,failureremains the source error, so this defer callsRemoveAllon the dead connection and can hang forever. Propagate or otherwise record anErrTimeoutfrom that close before relying on this check.
var failure error
defer func() {
if errors.Is(failure, ErrTimeout) {
return
}
_ = fsys.RemoveAll(tmpPath)
}()
if failure = copyAndVerifyUpload(fsys, tmpPath, local); failure != nil {
remotefs/winfile.go:519
- Although
Closenow returnsErrTimeout, the Windows convenience helpers still call it viadefer f.Close()and discard the result (winfs.go:103,309,323). ThusReadFile,WriteFile, andReadDircan report success after the quit timed out and the WinRM session died; propagate the close error in those helpers (or otherwise ensure a timeout is not swallowed).
// The file itself is closed by now, so a helper that merely exited ahead
// of the quit is not a failure. A quit that timed out is: the session
// died rather than shut down, and the caller would otherwise carry on
// against a dead host -- Upload goes straight from here to checksumming
// the file it just wrote.
if errors.Is(quitErr, ErrTimeout) {
return f.pathErr(OpClose, quitErr)
- Files reviewed: 12/12 changed files
- Comments generated: 2
- Review effort level: Lite
bff41fb to
438d7af
Compare
There was a problem hiding this comment.
🔵 Needs a closer look
Transfer progress is reported only after a full chunk completes, which can incorrectly trigger the idle timeout on slow, continuously progressing links.
Review details
Suppressed comments (1)
remotefs/winfile.go:256
io.PipeWriter.Writedoes not return until the entire 32 KiB slice has been consumed, sodog.progress()is not called when individual bytes move; it is called only after a whole chunk completes. A continuously progressing link that takes more thancommandTimeoutto deliver one chunk is therefore aborted as idle, despite the documented idle-timeout contract and the claim that slow transfers may outlast the timeout. Progress needs to be reported from a lower-level write/read boundary (or the protocol needs an explicitly documented per-chunk minimum rate), rather than only after this blocking write returns.
n, err := f.stdin.Write(p[written:min(written+payloadChunkSize, len(p))])
- Files reviewed: 14/14 changed files
- Comments generated: 0 new
- Review effort level: Lite
438d7af to
b055155
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved timeout and cleanup paths can still hang or incorrectly skip temporary-file removal.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (2)
remotefs/upload.go:117
- The cleanup decision is based on
errors.Is(failure, ErrTimeout)after aggregating everyCopyFromerror. A source reader is allowed to return this exported sentinel itself;io.Copythen propagates it throughremote.CopyFromwhile the remote file can still close successfully, butUploadwill incorrectly skip removing the temporary file. Keep the session-timeout signal separate from the copy error (for example, derive it from the remote close/typed session error) instead of classifying the aggregate error by sentinel alone.
var failure error
defer func() {
if errors.Is(failure, ErrTimeout) {
return
}
_ = fsys.RemoveAll(tmpPath)
}()
if failure = copyAndVerifyUpload(fsys, tmpPath, local); failure != nil {
remotefs/winfile.go:315
io.CopyNcan block insidedst.Writeaftersrc.Readhas reported progress. If callers pass a backpressured writer such as anio.PipeWriterwith no reader, the watchdog will abort and close the remote pipes aftercommandTimeout, but that cannot interrupt the caller's writer, soCopyTostill never returns. Run the copy behind a cancellation/return boundary or explicitly define and enforce a non-blocking destination contract; as written this does not bound allCopyTowaits.
n, err := io.CopyN(dst, src, resp.N-total)
total += n
if err != nil {
return total, f.pathErr(OpCopyTo, fmt.Errorf("copy: %w", f.abortCause(err)))
- Files reviewed: 14/14 changed files
- Comments generated: 1
- Review effort level: Lite
…ions Fixes k0sproject#472. Both protocol/winrm.(*command).Wait() and the rigrcp session behind a remotefs file on a Windows host could block forever when the underlying WinRM session had silently died -- observed in practice as a 40+ minute hang in launchpad's smoke-windows CI, right after a Windows host reboot, during a remotefs.Upload call. A full goroutine dump from the test timeout panic pinned both stuck points precisely. command.Wait() called c.wg.Wait() then the underlying masterzen/winrm Command.Wait(), neither of which take a context or have an internal timeout. StartProcess already receives a ctx and uses it to start the command, but never threaded it into Wait(), so a caller-supplied deadline had no effect on how long Wait() could block. Thread ctx through the command struct and race it against completion in Wait(); callers that pass context.Background() see unchanged (unbounded) behaviour, callers with a deadline are now honoured. Giving up on a command needs care, because the goroutines copying its output outlive Wait(). StartProcess hands them a detachable writer that Wait() cuts loose from the caller's writers before returning, and closes the command and the shell in the background -- in separate goroutines, so a close that never returns cannot strand the other -- since each is a synchronous request to a host that has very likely stopped answering. Detaching is deliberately non-blocking, and so cannot interrupt a write already inside the caller's writer: an io.Writer offers no way to do that, and waiting for one would make Wait unbounded again, which is the whole point of the change. A writer whose Write can park is in any case one the caller is still reading from at the other end -- ExecReaderContext's pipe -- rather than one it is about to recycle. The exported documentation on StartProcess now states this contract. Rig's own writers are made safe for that window rather than relying on it being narrow. cmd.ExecOutputContext drops its stdout buffer instead of pooling it when the caller's context expired. The stderr buffer behind ExecOptions.ErrString, which waiterWrapper.Wait reads the moment Wait returns, is mutex guarded. iostream.ScanWriter and redact.Writer, the per-line trace writers waiterWrapper.Wait closes at the same moment, guard the state Write and Close share. Closing them therefore waits for a write in flight, so on the abandoned path waiterWrapper.Wait hands that to a goroutine rather than doing it itself: they are still closed, releasing the scanner goroutine each of them owns, just not on the caller's time. Command input is copied through a context-aware reader, so that goroutine stops at the next read boundary instead of outliving an abandoned command. winFile had no bound anywhere: not on the write of a rigrcp request, not on the wait for its response, and not on the payload transfers in Write, Read and CopyTo that happen between two round trips. Bound all of them with commandTimeout, as an idle timeout rather than a duration budget -- the countdown restarts on every step forward, and Write chunks its payload so a single large call reports progress as it goes. A big but still progressing transfer is not killed for taking longer than 30s. What counts as a step differs by direction, and commandTimeout says so: a round trip completing, each successful read of a payload coming back, but on the way out only a whole chunk handed to the connection, since io.PipeWriter.Write does not return before that -- a floor of one payloadChunkSize per commandTimeout, roughly a kilobyte a second. Reaching that timeout means the session is dead, so winFile.abort() tears it down rather than returning and leaving it half-open: closing the far ends of the pipes releases everything parked on them (the response reader, a payload write, a payload read) with the timeout error instead of leaking those goroutines, cancelling the command context stops the remote helper, and the file is marked closed so later operations fail fast. Callers can recognise the new failure mode through the exported remotefs.ErrTimeout, which also wraps context.DeadlineExceeded. The watchdog decides whether to abort from the time of the last progress, under its own lock, rather than leaving it to the timer: neither Stop nor Reset has any effect on an AfterFunc callback that has already begun, and such a callback can sit waiting for that lock while the operation it covers finishes or moves on. Without this, a watchdog expiring in a photo finish could tear down a session that had just succeeded, or one that had just reported progress. winFileDirBase.closed becomes an atomic.Bool. It was already written from the goroutine watching the rigrcp command exit while callers read it, and the watchdog adds a third goroutine to that. Errors from a rigrcp round trip no longer carry an fs.PathError of their own, leaving the public operation to name itself: a stalled Read now reports "read" rather than "open", and the ops that already wrapped these errors stop nesting two path errors. Close reports a quit that timed out instead of only logging it, and waits for the helper to exit rather than taking the local write as proof it shut down -- the file is closed by then either way, but the session may not be, and Upload goes straight from there to checksumming the file it just wrote. While in that path: rigrcp.ps1's quit handler called Close-Dipose, which is not a thing, so every quit threw and emitted a stray error instead of disposing the streams. Two things the new timeout path ran into. rigrcp.ps1 answered "w N" with a single $in.Read of N bytes and called anything less a short read, but $in is a FileStream over stdin, which returns as soon as any data is there, and a payload larger than one transport chunk has always arrived in several; it now accumulates to the declared count. And Upload skips its temp-file cleanup after a session timeout: RemoveAll would run against the connection that just died and block there, leaving the public call unbounded on the very path this adds. For that to work the timeout has to survive the trip out. winFile records why it aborted, because closing the pipes is what releases a parked transfer but io.Pipe keeps the first close error it is given -- a transfer that raced the rigrcp command exiting came back with a plain closed pipe, and Upload would not have recognised the timeout it has to skip cleanup for. copyAndVerifyUpload joins a failed close onto a failed copy instead of dropping it, and WinFS.ReadFile, WriteFile and ReadDir report a close that timed out rather than discarding it in a defer, so they cannot return success over a session that has gone. Tests cover each previously unbounded point -- the command write, the response wait, the Write payload and the Read payload -- asserting both that the operation returns promptly with ErrTimeout and that the session was torn down, plus that slow but still progressing Write and CopyTo transfers complete, and that neither half of the watchdog photo finish -- stop() or progress() -- loses to a callback that has already begun. The winrm side is tested too. command now holds the shell and the command behind the small unexported interfaces its own methods use, rather than *winrm.Shell and *winrm.Command directly, which is seam enough to stand a fake in: the concrete types talk SOAP to a real host and cannot be faked otherwise. That covers both halves of Wait -- finishing inside the deadline, exit codes, waiting for the output copies, and the deadline passing first, where the test pins the prompt context error, the writers being out of the caller's reach by the time Wait returns, and the teardown landing in the background. Without the fix that last test does not fail, it hangs, which is the shape of k0sproject#472 itself. Also covered: the detachable writer, including that detaching does not wait behind a parked write, and the context-aware input reader. End-to-end validation is via the launchpad smoke-windows re-run this was found on. Written by AI: claude-sonnet-5, claude-opus-5 Co-Authored-By: James Nesbitt <jnesbitt@mirantis.com> Signed-off-by: James Nesbitt <jnesbitt@mirantis.com> Signed-off-by: Kimmo Lehto <klehto@mirantis.com>
b055155 to
9ad5ed7
Compare
|
Ok that grew quite dramatically.. |
* refactor: migrate to rig v2 Rebased onto main. Resolved the SLES InstallMCR conflict to retain the --allow-vendor-change fix (PRODENG-3623 / #652) expressed in rig v2's API. Build/test fixes required by the migration: go.mod/go.sum tidied for github.com/k0sproject/rig/v2, validate_facts_test.go updated to rig v2 CompositeConfig/ssh.Config, and %w error wrapping in the EL/SLES/Ubuntu configurers. Adds TestUpgradeModernClusterFromLegacy. Signed-off-by: Kimmo Lehto <kimmo.lehto@gmail.com> Written by AI: claude-sonnet-5 * fix: restore GET-based MKE health check and unblock its deadlock Two bugs made every Linux smoke job hang until the harness killed it. MKE installed fine, then 'Validating MKE Health' looped 'waiting for MKE at https://<ip>/_ping to become healthy' every 30s until the test panicked. 1. GET -> HEAD regression (introduced by the rig v2 migration) CheckHTTPStatus was switched to remotefs.HTTPStatusInsecure, which issues a HEAD request on both PosixFS (curl -kIso) and WinFS (Method='HEAD'). The previous per-OS implementations issued a GET, and the MKE health endpoints do not answer HEAD with 200, so the check could never succeed. rig v2's Windows path additionally does not skip TLS verification on PowerShell 5.x, which breaks against MKE's self-signed certs. Reinstate HTTPStatus on the Linux and Windows configurers (GET, TLS skipped), restoring the pre-migration semantics, and have CheckHTTPStatus delegate to the configurer again. Keeps the per-OS logic in the per-OS layer. 2. pingHost deadlock (pre-existing on main, latent until the check fails) On the error path pingHost sent twice on errCh (the error, then nil) while errCh is buffered to len(hosts) and only drained after wg.Wait(). Any failure overflowed the buffer, blocked the second send, and left waitgroup.Done() unreached, so wg.Wait() blocked forever -- converting a bounded ~5 minute failure into an indefinite hang. Now sends exactly one value and defers Done. Renamed the pingHost host parameter for varnamelen after the added comment extended the function scope. Written by AI: claude-sonnet-5 * fix: restore WinRM HTTPS port derivation lost in the rig v2 migration Both Windows smoke jobs failed at Open Remote Connection with 'connect <ip>:5985: All attempts fail'. Windows hosts listen for WinRM over TLS on 5986; 5985 is the plaintext port. creasty/defaults applies rig's winRM port struct-tag default (5985) during Host.UnmarshalYAML, before rig ever sees the config, so the port is never zero by the time rig defaults it. rig v0 corrected this by bumping 5985 -> 5986 when useHTTPS was set; rig v2 only derives the port when it is zero (and only infers useHTTPS when the port is already 5986). A host with 'useHTTPS: true' and no explicit port -- exactly what the terraform modules generate -- was therefore left attempting TLS against the plaintext port. Restore the bump in Host.UnmarshalYAML so existing configs keep working without having to name the port explicitly. Adds TestHostWinRMHTTPSPortDefault, which covers the compatibility matrix (useHTTPS with no port, with 5985, with 5986, a custom port, and no useHTTPS). Verified the test fails without the fix, reproducing the CI symptom exactly (expected 5986, got 5985). Written by AI: claude-sonnet-5 * fix: stop Windows hosts poisoning Linux OS detection Mixed Linux/Windows clusters failed OS detection on every Linux host: Detect host operating systems => failed to resolve configurer for <host>: unsupported OS: linux rig's os.DefaultRegistry holds [ResolveLinux, ResolveLinuxCompat, ResolveWindows, ResolveDarwin] and is a process-global. On a successful match it promotes the winning resolver to the front of the slice so later hosts hit it first. That optimisation is only sound while no resolver matches a superset of another. ResolveLinuxCompat breaks it: it is a fallback for hosts with no /etc/os-release and reports ID "linux" for any Linux host at all. Resolving a Windows host swaps ResolveWindows from index 2 to index 0, which leaves the order [Windows, LinuxCompat, Linux, Darwin] -- the fallback now sits ahead of the real resolver. Every Linux host detected after a Windows host is reported as "linux", matches no configurer and fails the phase. This is why smoke-fips and smoke-windows failed while smoke-modern, smoke-legacy and smoke-upgrade passed: only the mixed clusters ever resolve a Windows host, and the CI logs show the Windows host resolving immediately before the Linux host failed. Give the client its own registry, identical to rig's minus the compat fallback, via rig.WithOSReleaseProvider. Reordering is then harmless because every remaining resolver matches exactly one OS family. Dropping the fallback also restores rig v0's semantics: v0 had no compat resolver, so an unreadable os-release produced a real error instead of a silent misclassification. Every OS launchpad supports ships an os-release file, so the fallback could never yield a usable configurer. The AMI was not at fault -- verified against a live Ubuntu 22.04 FIPS instance, where rig resolves ID=ubuntu Version=22.04 correctly, 20 runs out of 20. Both tests fail without the fix, the first reproducing the exact CI symptom (expected "ubuntu", got "linux"). Written by AI: claude-sonnet-5 * fix: retry WinRM auth rejections while waiting for hosts smoke-fips abandoned a Windows host on the first connect attempt: Open Remote Connection => connect 44.211.52.127:5986: All attempts fail: #1: retry: abort condition reached after 1 attempts: operation cannot be completed: create shell: http response error: 401 - invalid content type The Connect phase exists to wait for hosts to become reachable, and on Windows an auth rejection is part of that wait: the WinRM HTTPS listener answers before provisioning has finished configuring authentication, so a freshly booted host returns 401 for a while with entirely correct credentials. rig v2 wraps 401/403 as protocol.ErrNonRetryable, and this phase's RetryIf honours that, so the wait ended on attempt 1 of 60. rig v0 did not classify these errors, so launchpad retried them and the condition healed itself. The migration swapped the predicate from ErrCantConnect to ErrNonRetryable and inherited the new classification with it. The contrast is visible within a single CI run: smoke-windows retried an i/o timeout to attempt 36 of 60, while smoke-fips aborted a 401 at attempt 1. Both are the same underlying condition - a Windows host whose provisioning has not finished - and the same host connected fine on the previous run, so this is timing, not credentials. Treat 401/403 as retryable again while still honouring ErrNonRetryable for everything else: bad certificates, host key mismatches and misconfigured bastions, none of which waiting can fix. The cost is that genuinely wrong credentials take the retry budget to report rather than failing at once. That is the right trade here: a cluster that would have come up must not fail, and the budget is bounded. Matching is by substring because the WinRM library returns untyped formatted errors and rig exports no sentinel; rig's own isAuthError does the same, and both message shapes it covers are matched. The test drives shouldRetryConnect, the predicate handed to RetryIf. Its three auth cases fail without this change while the four others pass. Written by AI: claude-sonnet-5 * fix: recognize exit code 3010 across rig v1/v2 error phrasing isExitCode3010 matched the exact substring "non-zero exit code: 3010", which is rig v1's wording. rig v2's WinRM transport formats the same error as "command exited with a non-zero exit code: exit code 3010" (note the doubled "exit code"), so the substring never matched and a successful-but-reboot-required MCR install (ERROR_SUCCESS_REBOOT_REQUIRED) was treated as a hard failure instead of triggering the reboot phase. Reproduced on smoke-windows and smoke-fips CI runs for PRODENG-3594: both failed identically with "failed to install container runtime: ... command exited with a non-zero exit code: exit code 3010" during "Install Mirantis Container Runtime on the hosts". Match on the exit code number via regexp instead of a fixed phrase, so future wording changes in either rig transport don't silently break reboot handling again. Added pkg/configurer/windows_test.go covering both known phrasings, an unrelated exit code, and a numeric substring collision (13010) that a naive `strings.Contains(err, "3010")` fix would have wrongly matched. Signed-off-by: James Nesbitt <jnesbitt@mirantis.com> * fix: bound Windows MCR install/uninstall/restart execs with a timeout smoke-windows CI hung for the full 60-minute test timeout: a Windows host rebooted mid-uninstall, reconnected successfully, and the very next remotefs.Upload call then hung indefinitely. A goroutine dump from the test's own timeout panic traced the root cause to rig v2: command.Wait() (protocol/winrm/connection.go) and the remotefs rigrcp helper's command loop (remotefs/winfile.go) had no timeout at all, so a WinRM session that silently died post-reboot could block the caller forever. Filed and fixed upstream as k0sproject/rig#472 / k0sproject/rig#473. The upstream fix only helps if a caller actually supplies a bounded context: rig's plain Exec/ExecOutput hardcode context.Background(), which never has a deadline. Add that bound here as defense in depth, independent of when the upstream fix lands: - pkg/configurer/host.go: extend the Host interface with cmd.ContextRunner so configurers can call ExecContext/ ExecOutputContext. - pkg/product/mke/config/host.go: add matching ExecContext/ ExecOutputContext wrapper methods on Host, mirroring Exec/ ExecOutput's existing sudo/SudoDocker routing (the promoted methods from the embedded *rig.Client would silently skip that routing). - pkg/configurer/windows.go: bound every Exec/ExecOutput call in InstallMCR, UninstallMCR, and RestartMCR -- the MCR lifecycle operations that run immediately around host reboots -- with a new windowsExecTimeout (15 minutes, generous for legitimate slow installs/image pulls, not a tight SLA). remotefs.Upload calls in these functions are unaffected by this change; they are already self-bounded by the upstream winfile.go fix regardless of caller context. Points the go.mod replace directive at the fork commit carrying the upstream fix (k0sproject/rig#473) until it merges and is tagged upstream, at which point the replace should be dropped and the version bumped normally. Added TestExecCtxIsBoundedAndCancelable pinning that the new helper actually carries a deadline and that cancel works. Verified: go build, go vet, full go test --tags 'testing' ./pkg/..., golangci-lint run pkg/configurer/... pkg/product/mke/config/... (one pre-existing, unrelated gofumpt finding in hosts.go, unchanged by this commit). End-to-end verification is the smoke-windows re-run this was found on. Signed-off-by: James Nesbitt <jnesbitt@mirantis.com> * test: temporarily exclude $ from generated Windows password Unblocks this branch's CI. launchpad.tf embeds the generated windows_password directly into the launchpad_yaml output's password/adminPassword fields, which launchpad's config loader then runs through envsubst (pkg/config/config.go). PRODENG-3751 made an unescaped "$word" in that YAML fail config loading loudly if the implied variable is unset, correctly replacing silent password truncation -- but the test's password generator was never updated to account for it, so smoke-windows/smoke-fips fail whenever the random password happens to contain "$" (e.g. run with password "Nq4@CLkhD6%HvwO&$K2f": "variable ${K2f} not set"). This is a workaround, not the fix: the real fix is to escape "$" as "$$" where launchpad.tf embeds the password into that YAML, so a literal "$" -- legitimate in a real password -- keeps working end to end, then restore "$" to this generator's symbol set. Tracked under PRODENG-3751. Written by AI: claude-sonnet-5 Signed-off-by: James Nesbitt <jnesbitt@mirantis.com> * fix: confirm swarm NodeID is reported before JoinWorkers returns smoke-windows failed at the (much later) Label nodes phase: failed to label node 100.53.69.105:5986 (): command result: process finished with error: Process exited with status 1 ("docker node update" requires exactly 1 argument. ...) The empty "()" is swarm.NodeID(h) having returned an empty string with no error. docker swarm join succeeding only means the join command itself completed; it does not guarantee this host's own docker engine has finished updating its local view of swarm state, particularly right after the reconnect JoinWorkers already does for Windows hosts (swarm join tears down and re-establishes the WinRM connection). LabelNodes runs several phases later and had no reason to expect this, so it used the empty NodeID as-is. Add a bounded retry (20 attempts, 3s delay) after joining -- and after the Windows reconnect -- confirming swarm.NodeID(h) actually returns a non-empty NodeID before JoinWorkers considers the host joined. Applies to all hosts, not just Windows, since the race is generic (docker's local swarm-state sync lag), even though it was only actually observed on Windows in this session's testing, likely because the Windows reconnect makes the race window more visible. No dedicated unit test: swarm.NodeID takes the concrete *Host type (not an interface), consistent with the rest of this package, so testing this meaningfully would need a live connection; verified via build, vet, golangci-lint (clean), the full unit suite, and the smoke-windows run this was found on. Written by AI: claude-sonnet-5 Signed-off-by: James Nesbitt <jnesbitt@mirantis.com> * chore: trigger a single clean CI run after AWS quota exhaustion from duplicate label runs Signed-off-by: James Nesbitt <jnesbitt@mirantis.com> --------- Signed-off-by: James Nesbitt <jnesbitt@mirantis.com> Co-authored-by: Kimmo Lehto <kimmo.lehto@gmail.com>
What
Bound every point where a WinRM command or a remote-file transfer could block forever on a session that has silently died.
Why
Fixes #472. Observed as a 40+ minute hang in
Mirantis/launchpad's smoke-windows CI, right after a Windows host reboot, during aremotefs.Uploadcall as part of an MCR uninstall. A goroutine dump from the test's own timeout panic pinned both stuck points precisely (see the issue for the full stacks).How
protocol/winrmcommand.Wait()takes thectxStartProcessalready receives and races it against completion.context.Background()callers see unchanged (unbounded) behaviour; callers with a deadline are now honoured.Wait().StartProcessnow wraps the caller's writers in adetachableWriterthatWait()cuts loose before returning, and closes the command and the shell in the background — in separate goroutines, so a close that never returns cannot strand the other — since each is a synchronous request to a host that has very likely stopped answering.io.Writeroffers no way to do that, and waiting for one would makeWait()unbounded again.StartProcess's documentation now states the contract — whenWait()returns a context error the command is still running, and the caller must not recycle or concurrently read the writers it passed in.cmd.ExecOutputContextdrops its stdout buffer instead of pooling it when the context expired; the stderr buffer behindExecOptions.ErrString— read bywaiterWrapper.Waitthe momentWaitreturns — is mutex guarded; andiostream.ScanWriterandredact.Writer, the per-line trace writers closed at that same moment, guard the stateWriteandCloseshare. Closing them therefore waits for a write in flight, so on the abandoned pathwaiterWrapper.Waithands that to a goroutine instead of doing it itself — they still get closed, releasing the scanner goroutine each one owns, just not on the caller's time.remotefswinFilehad no bound anywhere: not on writing a rigrcp request, not on waiting for its response, and not on the payload transfers inWrite,ReadandCopyTothat happen between two round trips. All are now bounded bycommandTimeout(30s, avarso tests can lower it).Writechunks its payload so one large call reports progress as it goes. A slow but progressing multi-megabyte upload is not killed for taking longer than 30s. What counts as a step differs by direction, andcommandTimeoutdocuments it: a round trip completing, each successful read of a payload coming back, but on the way out only a whole chunk handed to the connection —io.PipeWriter.Writedoes not return before that — so the write side has a floor of onepayloadChunkSizepercommandTimeout, roughly a kilobyte a second.winFile.abort()tears it down instead of leaving it half-open. Closing the far ends of the pipes releases everything parked on them (the response reader, a payload write, a payload read) with the timeout error rather than leaking those goroutines; cancelling the command context stops the remote helper; the file is marked closed so later operations fail fast.remotefs.ErrTimeoutso callers can recognise this failure mode. It wrapscontext.DeadlineExceeded, soerrors.Is(err, context.DeadlineExceeded)matches too.StopnorResethas any effect on anAfterFunccallback that has already begun, and such a callback can sit waiting for that lock while the operation it covers finishes or moves on. Without this, a watchdog expiring in a photo finish could tear down a session that had just succeeded, or one that had just reported progress.fs.PathErrorof their own, leaving the public operation to name itself: a stalledReadreportsreadrather thanopen, and callers that already wrapped these errors stop nesting two path errors.rigrcp.ps1accumulates aw Npayload up to the declared count.$inis aFileStreamover stdin, soReadreturns as soon as any data is there, and a payload larger than one transport chunk has always arrived in several — the single-Readshort-read check could reject a perfectly goodWinFS.WriteFile. Predates this PR (bytes.Readerimplementsio.WriterTo, soWriteFilehands over the whole payload at once), surfaced while reviewing it.Uploadskips its temp-file cleanup after a session timeout:RemoveAllwould run against the connection that just died and block there, leaving the public call unbounded on the very path this adds.winFilerecords why it aborted, because closing the pipes is what releases a parked transfer butio.Pipekeeps the first close error it is given — a transfer that raced the rigrcp command exiting came back with a plain closed pipe, andUploadwould not have recognised the timeout it has to skip cleanup for.copyAndVerifyUploadjoins a failed close onto a failed copy rather than dropping it, andWinFS.ReadFile/WriteFile/ReadDirreport a close that timed out instead of discarding it in a defer, so they cannot return success over a session that has gone.Closereports a quit that timed out instead of only logging it, and waits for the helper to actually exit rather than treating the local write as proof it shut down. The file is closed by then either way, but the session may not be, andUploadgoes straight from there to checksumming the file it just wrote.rigrcp.ps1's quit handler calledClose-Dipose; the function isClose-Dispose. Every quit threw, got caught by the loop's handler and emitted a stray error object instead of disposing the streams. Harmless before — nothing read a response toq— but not something to leave in place now thatClosewaits on that exit.winFileDirBase.closedbecomes anatomic.Bool— it was already written from the goroutine watching the rigrcp command exit while callers read it, and the watchdog adds a third goroutine.Testing
remotefs/winfile_internal_test.go: each previously unbounded point (command write, response wait,Writepayload,Readpayload) returnsErrTimeoutin well under a second instead of hanging, and each asserts the session was actually torn down. Two positive tests pin the idle-vs-budget semantics: a slow but steadyWriteandCopyTothat both outlastcommandTimeoutstill complete. Two tests pin the watchdog photo finish, one per direction: neitherstop()norprogress()loses to a callback that has already begun. Happy path and "session ended" path unaffected.protocol/winrm/connection_test.go:commandnow holds the shell and the command behind the small unexported interfaces its own methods use, rather than*winrm.Shell/*winrm.Commanddirectly — seam enough to stand a fake in, since the concrete types talk SOAP to a real host. Both halves ofWaitare covered: finishing inside the deadline, exit codes, waiting for the output copies, and the deadline passing first (prompt context error, writers out of the caller's reach by the timeWaitreturns, teardown landing in the background). Without the fix that last test does not fail — it hangs, which is WinRM command.Wait() and remotefs upload can hang forever on a dead session #472's own shape. Also covered:detachableWriter(including thatdetach()returns with a write parked inside the caller's writer) andctxReader.go build,go vet,go test -race ./...andgolangci-lint run ./...all clean.Written by AI: claude-sonnet-5, claude-opus-5