Skip to content

Fix linux detection when cluster contains windows hosts - #420

Merged
kke merged 8 commits into
k0sproject:mainfrom
james-nesbitt:fix/provider-fallback-ordering
Aug 13, 2026
Merged

Fix linux detection when cluster contains windows hosts#420
kke merged 8 commits into
k0sproject:mainfrom
james-nesbitt:fix/provider-fallback-ordering

Conversation

@james-nesbitt

@james-nesbitt james-nesbitt commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

What this accomplishes

Linux hosts are identified correctly again. They were reported as ID: "linux" with no version, but only in fleets that also contained a Windows host. Found migrating Mirantis/launchpad to v2, where it surfaced as unsupported OS: linux. The consumer got a plausible-looking Release instead of an error, which made it expensive to diagnose from outside.

Detection no longer depends on which host resolved first. Provider.Get used to move the matching factory to the front of the list, so one host's lookup changed the next one's. Beyond the bug above, this silently broke sudo: resolving an ordinary sudo host promoted Sudo past UID0Noop, and every root host after that had its commands wrapped in sudo -n -- sh -c ... instead of run directly. Lookups now consult factories in registration order and never reorder them.

Detection for separate hosts now runs concurrently. Reordering mutated the shared slice, so Get held an exclusive lock across the factory probes — which are remote commands — serialising all OS / init-system / sudo / package-manager detection process-wide. Get takes a read lock now.

Callers can override a built-in factory. Previously they couldn't: registries only append, and ResolveLinuxCompat matches every Linux host os-release cannot name, so a resolver added for such a host was unreachable. rig's own factories avoid this by standing down for hosts a more specific factory handles, but that is only available to whoever owns the broad factory.

  • Provider.RegisterFirst(f) puts a factory at the front, ahead of everything registered before or after it.
  • RegisterDefaults(reg) in os, sudo, initsystem, packagemanager and remotefs registers what DefaultRegistry holds, so building your own registry doesn't mean listing rig's factories by hand and missing ones added later.
reg := packagemanager.NewRegistry()
mypkg.RegisterFoo(reg)               // tried first
packagemanager.RegisterDefaults(reg) // then everything rig ships, now and later

Worth a reviewer's attention

  • ResolveLinux now requires an ID from os-release. kv.Decoder returns no error for empty input, so it previously claimed hosts with ID: "". Those hosts now reach the compat resolver, which can still name them from their package manager.
  • A Factory may now be called concurrently, and says so. Audited all five registries: none closes over mutable state.
  • RegisterFirst called twice means the most recent call wins. This is deliberate — "first" means first.

Testing

Every guard was confirmed to fail with the change it covers reverted, including the RegisterFirst ones against an appending implementation. golangci-lint 0 issues · go vet clean · full suite green · -race clean. Each commit is green on its own, so the history bisects.

The RegisterFirst would let an operator override the built-in zypper factory in #419, but newUniversalPackageManager is unexported, so changing one install flag still means reimplementing PackageManager.


Credit to @james-nesbitt for the diagnosis, the reproduction and the test fixtures, which this keeps. Dropping the reordering was the alternative offered in the original description — thanks for laying the options out.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR prevents “superset” resolver factories (intended as last-resort fallbacks) from being promoted ahead of more specific factories during Provider.Get lookups, which previously could cause incorrect OS detection in the process-global os.DefaultRegistry.

Changes:

  • Add Provider.RegisterFallback and a separate fallback factory list that is consulted only after all normal factories, and never promoted/reordered.
  • Register ResolveLinuxCompat as a fallback in the OS default registry (and via RegisterLinuxCompat) to avoid overtaking ResolveLinux.
  • Add targeted tests in plumbing and os to reproduce and guard against the ordering/promotion regression.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
plumbing/provider.go Adds fallback factory support and updates Get/GetAll to consult fallbacks last without promotion.
plumbing/provider_test.go Adds tests ensuring fallbacks aren’t promoted and are included by GetAll.
os/linux_compat.go Switches Linux compat resolver registration to use RegisterFallback.
os/defaultprovider.go Ensures DefaultRegistry registers ResolveLinuxCompat as a fallback to avoid ordering corruption.
os/defaultprovider_test.go Adds regression tests reproducing the Windows-then-Linux misclassification and verifying fallback behavior remains intact.
Suppressed comments (1)

plumbing/provider.go:70

  • The GetAll doc comment also refers to factories "not error"ing, but factories indicate matches via the boolean return value. Consider rewording to avoid implying factories can error.
// GetAll retrieves all values of type T from the Factories in the Provider,
// followed by any registered with RegisterFallback.
// If none that does not error can be found, the error supplied at creation time is returned.
func (p *Provider[R, T]) GetAll(r R) ([]T, error) {

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread plumbing/provider.go Outdated
Comment on lines +38 to +42
// Get retrieves the first value of type T from the Factories in the Provider.
// If none can be found, the error supplied at creation time is returned.
// The first factory that does not error is moved to the front of the list to optimize
// future lookups.
// future lookups. Factories added with RegisterFallback are tried last, in
// registration order, and are never reordered.
Comment thread os/linux_compat.go Outdated
Comment thread plumbing/provider_test.go Outdated
@kke

kke commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Nice.

This seems to be similar problem as there was with #411

Copilot's problems seem to be nitpicking about comments only.

I can finish this.

@kke kke changed the title fix(plumbing): stop fallback factories overtaking specific ones fix(os): stop the Linux compat resolver shadowing ResolveLinux Aug 10, 2026
@kke
kke requested a balanced review from Copilot August 10, 2026 11:48

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.

Suppressed comments (2)

os/defaultprovider.go:20

  • This registry tier does not implement the complementary resolver predicates promised by the PR. ResolveLinux still returns success for an empty-but-successfully-read os-release (linux.go:23-36), so this fallback is not reached and callers still receive an empty ID; ResolveLinuxCompat also still accepts hosts with a valid os-release (linux_compat.go:37-78) when invoked directly or registered normally. Please share the os-release read, require a non-empty ID in ResolveLinux, and make the compat resolver decline when that ID is present.
		// ResolveLinuxCompat accepts any Linux host, so it must never be able to
		// answer ahead of ResolveLinux. See RegisterFallback.
		provider.RegisterFallback(ResolveLinuxCompat)

plumbing/provider.go:42

  • Keeping move-to-front for ordinary factories leaves precedence unstable. In the existing defaults, resolving a non-root sudo host can promote Sudo ahead of UID0Noop, causing a later root host with sudo installed to be wrapped unnecessarily; likewise, promoting WinSCM can put SysVinit ahead of Systemd on a systemd host with /etc/init.d. The exclusive lock also remains held across remote probes, so host detection is still serialized. Preserve registration order and use the read lock as described by the PR.
// The first factory that does not error is moved to the front of the list to optimize
// future lookups. Factories added with RegisterFallback are tried last, in
// registration order, and are never reordered.

@kke
kke force-pushed the fix/provider-fallback-ordering branch from 9f4c72a to f7fc9a8 Compare August 10, 2026 12:03
@kke
kke requested a balanced review from Copilot August 10, 2026 12:03

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated no new comments.

Suppressed comments (2)

plumbing/provider.go:27

  • This reverses the precedence implemented below: Register appends, and Get checks from the beginning, so a factory added later cannot shadow an earlier factory; it can only be shadowed by it. Rephrase the guidance in terms of callers assembling registries in different orders so the public documentation does not misstate registration behavior.
// stands down for any host os-release can identify. A registry stays open for
// registration, so a factory that leans on being registered first can be shadowed
// by one a caller adds later.

plumbing/provider.go:9

  • Get and GetAll can be invoked concurrently with the same input, and the read lock allows those calls to enter the same factory simultaneously. Restricting this warning to “different inputs” understates the new concurrency contract and could lead external factory implementations to keep unsynchronized per-input state. Document that factories must be safe for concurrent calls regardless of whether the input is the same.

This issue also appears on line 25 of the same file.

// A Factory may be called concurrently with itself for different inputs, so it
// must not depend on being called one at a time.

@kke
kke marked this pull request as ready for review August 10, 2026 12:49
@kke
kke force-pushed the fix/provider-fallback-ordering branch from e29e57e to d23a8c2 Compare August 10, 2026 12:51
@kke
kke requested a balanced review from Copilot August 12, 2026 12:15
@kke
kke force-pushed the fix/provider-fallback-ordering branch 2 times, most recently from e58bf44 to 3ecfb2f Compare August 12, 2026 12:21
@kke
kke force-pushed the fix/provider-fallback-ordering branch from 3ecfb2f to 71e85d3 Compare August 12, 2026 12:23

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated no new comments.

Suppressed comments (3)

os/linux_compat.go:50

  • This order-independence claim is broader than the implementation: the compat resolver excludes only inputs handled by ResolveLinux, so a caller's custom resolver for an ID-less Linux host can still be shadowed when registered after compat. Narrow the comment to the two built-in Linux resolvers to avoid promising unsupported extensibility.
	// host keeps the two complementary however the registry happens to be
	// ordered, including once a caller has added resolvers of their own. yum

os/linux_compat.go:98

  • “The other resolvers” implies order independence with arbitrary registered resolvers, but this factory only self-excludes for ResolveLinux. A later custom resolver for Linux without a usable os-release remains unreachable, so document the actual pairwise guarantee.
// It excludes itself on any host ResolveLinux can identify, so it does not matter
// when it is registered relative to the other resolvers.

os/linux_compat.go:46

  • The public function documentation still says this fallback is only for absent os-release files, but the new readOSRelease check intentionally also routes unreadable, malformed, and ID-less files here. Update the contract so callers understand the expanded behavior.

This issue also appears in the following locations of the same file:

  • line 49
  • line 97
	// ResolveLinux identifies any host whose os-release names the distribution,

@kke

kke commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Yeah I think the optimization has to go.

The resolvers should be run in registration order and there should be a RegisterFirst or similar so consumers can prepend stuff without the built-ins taking precedence.

The more correct way for consumers to use it would still be cloning the default registry and then prepending stuff there, as mutating the global one is a bit ugly.

@kke
kke marked this pull request as draft August 12, 2026 13:44
james-nesbitt and others added 6 commits August 12, 2026 17:27
ResolveLinuxCompat is documented as a resolver for Linux hosts "where
/etc/os-release and /usr/lib/os-release are absent", but it only checked
`uname | grep -q Linux` and so matched every Linux host, reporting
ID "linux" with no version. Nothing but its position in the registry kept
it from answering in ResolveLinux's place, and a registry stays open for
registration, so that position is not something the package can guarantee.

Found while migrating Mirantis/launchpad from rig v0 to v2: Linux hosts
failed configurer lookup with "unsupported OS: linux", but only in
clusters that also contained a Windows host. The consumer sees a
plausible-looking Release rather than an error, which makes it awkward to
diagnose from the outside.

Fix the predicate rather than the ordering. The resolver now stands down
for any host whose os-release names the distribution, which is exactly
when ResolveLinux succeeds -- both go through readOSRelease, so they
cannot drift apart. This is how the repository already handles
overlapping factories: yum declines when dnf is present, and SysVinit
declines when systemd is (k0sproject#409).

Also require an ID from os-release. kv.Decoder returns no error for empty
input, so ResolveLinux would otherwise claim a host with ID "", which is
useless to a caller and would make the self-exclusion above fire for a
host ResolveLinux cannot actually identify. Those hosts now reach the
compat resolver, which can still name them from their package manager.

Co-Authored-By: James Nesbitt <jnesbitt@mirantis.com>
Signed-off-by: Kimmo Lehto <klehto@mirantis.com>
Signed-off-by: James Nesbitt <jnesbitt@mirantis.com>
Signed-off-by: Kimmo Lehto <klehto@mirantis.com>
…ering

Provider.Get moved the factory that matched to the front of the list to
save probes on later lookups. That is only sound when no two factories can
match the same input, and sudo.DefaultRegistry violates it: the list is
[WindowsNoop, UID0Noop, Sudo, Doas], and a root host that also has sudo
installed matches both UID0Noop (`[ "$(id -u)" = 0 ]`) and Sudo
(`sudo -n -- sh -c true`). Resolving one ordinary sudo host moved Sudo to
the front, so every root host resolved afterwards had its commands wrapped
in `sudo -n -- sh -c ...` instead of run directly. Neither factory is a
superset of the other, so the resolution used elsewhere -- having the
broader factory exclude itself -- would cost an `id -u` probe on every
non-root host to express what the registration order already says.

Drop the reordering. Registration order now means what it says, and a
registry that does need a specific order gets it. The saving was small to
begin with: LazyService memoizes per host, so this only ever helped host
declines a platform it cannot serve via a local IsWindows() with no round
trip.

It also cost more than it saved. Reordering mutated the shared slice, so
Get had to hold an exclusive lock across the factory probes -- which are
remote commands -- serialising OS, init system, sudo and package manager
detection across every host in the process. Get now takes a read lock and
detection for separate hosts runs concurrently, so Factory documents that
it may be called concurrently. Audited all five registries: no factory
closes over mutable state.

Tests, each confirmed to fail with the reordering restored:
- TestGetPreservesRegistrationOrder: two factories matching one input, the
  earlier wins, and an intervening lookup does not change that.
- TestGetIsSafeForConcurrentUse: 64 parallel lookups all observe the same
  order (run under -race).
- TestGetLetsASelfExcludingFactoryBeRegisteredFirst: a broader factory
  that excludes the specific one's inputs gives the same answers from
  either position, which is what lets a caller extend a built registry.
- sudo.TestDefaultRegistryPrefersNoopForRoot: reproduces the sudo bug
  above end to end on the real DefaultRegistry.

Signed-off-by: Kimmo Lehto <klehto@mirantis.com>
A registry a package exports stays open for registration, so a factory a
caller adds always lands behind the ones already there. Where one of those
matches a superset of the inputs the caller's factory handles -- as
os.ResolveLinuxCompat matches every Linux host os-release cannot name --
the caller's factory is never reached, and the self-exclusion idiom is no
help to them: the factory that would have to stand down is not theirs to
change.

RegisterFirst inserts at the front of the list, ahead of everything already
registered and everything Register appends afterwards. Called more than
once, the most recent call is the one consulted first.

Lookups still walk the factories in registration order and never reorder
them, so this does not bring back the promotion dropped in 71e85d3.

Signed-off-by: Kimmo Lehto <klehto@mirantis.com>
Building a registry of your own is the documented way to put a factory
ahead of a built-in, but it meant listing rig's own factories by hand, and
a registry assembled that way silently misses factories added in later
versions.

RegisterDefaults registers what DefaultRegistry holds, so a caller can take
the built-ins as a set and keep getting new ones. Each DefaultRegistry is
now built on top of it, which keeps the two from drifting apart.

The factories are appended, so a caller's own factory takes precedence by
being registered before the call or with RegisterFirst; both are documented
on RegisterDefaults and covered by TestRegisterFirstOverridesTheCompatResolver,
which pins that an appended resolver is shadowed by ResolveLinuxCompat and a
RegisterFirst one is not.

sudo.RegisterDefaults documents that its order is load-bearing: a root host
with sudo installed matches both RegisterUID0Noop and RegisterSudo.

Signed-off-by: Kimmo Lehto <klehto@mirantis.com>
The exported doc still described it as the resolver for hosts where the
os-release files are absent, but readOSRelease also reports no match for a
file that cannot be read or does not carry an ID, and those hosts reach this
resolver too. readOSRelease itself already documented the wider contract, so
only the exported side was stale.

The order-independence note claimed more than the code does. Standing down
on hosts os-release can name keeps this resolver complementary with
ResolveLinux from either position, but it still matches every other Linux
host, so a resolver a caller appends for one of those is not reached. Say
that, and point at RegisterFirst.

Signed-off-by: Kimmo Lehto <klehto@mirantis.com>
Adds the RegisterFirst section, switches the build-your-own-registry recipe
to RegisterDefaults, and drops the claim that a lookup moves the matched
factory to the front, which stopped being true in 71e85d3.

The override section says what RegisterFirst is for rather than just what it
does: self-exclusion is the better pattern where it is available, and it is
only available to whoever owns the broad factory.

Signed-off-by: Kimmo Lehto <klehto@mirantis.com>
kke added 2 commits August 12, 2026 17:27
…test

TestRegisterFirstStaysAheadOfLaterRegistrations passed with RegisterFirst
appending instead of inserting, because nothing was registered before the
call: appending put it first too. Register a factory on each side of the
call so the assertion distinguishes the two, and check the whole order via
GetAll rather than only the winner.

ExampleProvider_RegisterFirst gives the method a runnable example in godoc.

Signed-off-by: Kimmo Lehto <klehto@mirantis.com>
The sudo test comment located the load-bearing UID0Noop-before-Sudo order
in DefaultRegistry, which is now assembled by RegisterDefaults; point at
where the order is actually written.

ResolveLinuxCompat claimed a resolver needs RegisterFirst "to be reached at
all", which contradicts the recipe in docs/EXTENDING.md: registering before
RegisterDefaults in a registry you build yourself reaches it too. Say ahead
of it, and name both ways of getting there.

Signed-off-by: Kimmo Lehto <klehto@mirantis.com>
@kke
kke force-pushed the fix/provider-fallback-ordering branch from 71e85d3 to c2d83cc Compare August 12, 2026 14:27
@kke
kke requested a balanced review from Copilot August 12, 2026 14:36
@kke kke changed the title fix(os): stop the Linux compat resolver shadowing ResolveLinux fix linux detection when cluster contains windows hosts, let consumers override built-ins Aug 12, 2026
@kke kke changed the title fix linux detection when cluster contains windows hosts, let consumers override built-ins Fix linux detection when cluster contains windows hosts Aug 12, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated no new comments.

Suppressed comments (1)

os/linux_compat.go:62

  • This second os-release probe can eliminate the fallback after a transient command failure. In the default order, ResolveLinux may decline because its first read failed; if this retry succeeds, compat also declines, and there is no later ResolveLinux attempt, so Get returns ErrNotRecognized. Avoid independently probing the same condition from both factories—either rely on the now-stable Linux-before-compat order and let compat handle the prior failure, or share a single probe result across the two resolvers.
	if _, ok := readOSRelease(conn); ok {
		log.Trace(context.Background(), "linux compat resolver: os-release identifies the host, deferring to the standard resolver",
			log.HostAttr(conn),
		)

		return nil, false

@kke
kke marked this pull request as ready for review August 13, 2026 07:42
@kke
kke merged commit 2691914 into k0sproject:main Aug 13, 2026
13 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.

3 participants