Fix linux detection when cluster contains windows hosts - #420
Conversation
3ba3070 to
9f4c72a
Compare
There was a problem hiding this comment.
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.RegisterFallbackand a separate fallback factory list that is consulted only after all normal factories, and never promoted/reordered. - Register
ResolveLinuxCompatas a fallback in the OS default registry (and viaRegisterLinuxCompat) to avoid overtakingResolveLinux. - Add targeted tests in
plumbingandosto 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.
| // 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. |
|
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. |
There was a problem hiding this comment.
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.
ResolveLinuxstill 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;ResolveLinuxCompatalso 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 inResolveLinux, 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
Sudoahead ofUID0Noop, causing a later root host with sudo installed to be wrapped unnecessarily; likewise, promotingWinSCMcan putSysVinitahead ofSystemdon 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.
9f4c72a to
f7fc9a8
Compare
There was a problem hiding this comment.
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:
Registerappends, andGetchecks 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
GetandGetAllcan 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.
e29e57e to
d23a8c2
Compare
e58bf44 to
3ecfb2f
Compare
3ecfb2f to
71e85d3
Compare
There was a problem hiding this comment.
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
readOSReleasecheck 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,
|
Yeah I think the optimization has to go. The resolvers should be run in registration order and there should be a 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. |
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>
…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>
71e85d3 to
c2d83cc
Compare
There was a problem hiding this comment.
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-releaseprobe can eliminate the fallback after a transient command failure. In the default order,ResolveLinuxmay decline because its first read failed; if this retry succeeds, compat also declines, and there is no laterResolveLinuxattempt, soGetreturnsErrNotRecognized. 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
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 asunsupported OS: linux. The consumer got a plausible-lookingReleaseinstead of an error, which made it expensive to diagnose from outside.Detection no longer depends on which host resolved first.
Provider.Getused 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 brokesudo: resolving an ordinary sudo host promotedSudopastUID0Noop, and every root host after that had its commands wrapped insudo -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
Getheld an exclusive lock across the factory probes — which are remote commands — serialising all OS / init-system / sudo / package-manager detection process-wide.Gettakes a read lock now.Callers can override a built-in factory. Previously they couldn't: registries only append, and
ResolveLinuxCompatmatches 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)inos,sudo,initsystem,packagemanagerandremotefsregisters whatDefaultRegistryholds, so building your own registry doesn't mean listing rig's factories by hand and missing ones added later.Worth a reviewer's attention
ResolveLinuxnow requires anIDfrom os-release.kv.Decoderreturns no error for empty input, so it previously claimed hosts withID: "". Those hosts now reach the compat resolver, which can still name them from their package manager.Factorymay now be called concurrently, and says so. Audited all five registries: none closes over mutable state.RegisterFirstcalled 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
RegisterFirstones against an appending implementation.golangci-lint0 issues ·go vetclean · full suite green ·-raceclean. Each commit is green on its own, so the history bisects.The
RegisterFirstwould let an operator override the built-in zypper factory in #419, butnewUniversalPackageManageris unexported, so changing one install flag still means reimplementingPackageManager.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.