From 7db57b9fff084750002830495d6047398221e0b0 Mon Sep 17 00:00:00 2001 From: Minseong Choi Date: Sun, 5 Jul 2026 01:36:07 +0900 Subject: [PATCH] feat(updater): add VersionGatherer extraction core and CLI gather seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Give the Runner a way to read each component's CURRENT version so it can be compared against the release sources already wired. Three pure extractors turn raw system text into an updates.Version, each fail-closed: - versionFromCLI — a ` --version` banner (k3s, cloudflared) - versionFromImageRef — a container image tag (felis-api) - versionFromJarName — a proxy jar filename (velocity) sysGatherer routes each Topology component to the right extractor over an injected seam; every path is exercised with a fake runner, mirroring how the release sources are proven against httptest. The load-bearing case is k3s: its Git tag "v1.36.2+k3s1" parses stable, but a registry cannot store '+', so the same build ships as image tag "v1.36.2-k3s1", which parses as a prerelease unless repaired. versionFromImageRef normalizes "-k3sN"/"-rke2rN" back to "+", so an image read and a CLI read agree instead of the image masquerading as a prerelease and being barred from comparison. Honest runtime state after this slice — a green suite is not "the updater runs against real infra": only the CLI seam (execRunner) is wired, so of the four tracked components just cloudflared is live end to end (gatherable AND Scheduled/appliable). k3s is CLI-gatherable but Notify-only. felis-api and velocity are NOT yet runtime-gatherable: their producing seams — a k8s read of the control-plane Deployment image, and an off-cluster jar inspection — are left nil, so both surface an explicit "gather seam not wired" error rather than a wrong version. felis-api self-update is therefore not functional yet. Remaining integration (tracked in doc.go): the two producing seams, the concrete Notifier (SMTP + in-game), the Applier (image bump, cloudflared swap), the `felis update` CLI + CronJob entry point, and the runtime append of the Pinned Minecraft fleet. --- internal/updater/doc.go | 26 +-- internal/updater/gatherer.go | 194 ++++++++++++++++++++ internal/updater/gatherer_integration.go | 35 ++++ internal/updater/gatherer_test.go | 214 +++++++++++++++++++++++ internal/updater/topology.go | 5 +- 5 files changed, 462 insertions(+), 12 deletions(-) create mode 100644 internal/updater/gatherer.go create mode 100644 internal/updater/gatherer_integration.go create mode 100644 internal/updater/gatherer_test.go diff --git a/internal/updater/doc.go b/internal/updater/doc.go index a575ad4..bbac56d 100644 --- a/internal/updater/doc.go +++ b/internal/updater/doc.go @@ -29,15 +29,21 @@ // routing/parse logic is verified, but that one component stays dark at runtime (its // Latest errors, degrading to "latest unknown") until a real repository is configured. // -// - NOT YET BUILT, but VERIFIABLE HERE (the next slice): the VersionGatherer's -// extraction core — command output (`k3s --version`), image tag -// (`rancher/k3s:v1.36.2-k3s1`) or jar filename → Version. That is logic over an -// exec/read seam, testable with a fake runner à la internal/reaper's ExecRunner, -// and load-bearing: a mis-read current version makes every plan wrong (spurious -// applies or missed upgrades). Only the seam's actual I/O is un-verifiable here. +// - ALSO BUILT + UNIT-VERIFIED: the VersionGatherer's extraction core and dispatch. +// Three pure extractors turn raw system text into a Version — a `--version` banner +// (k3s, cloudflared), a container image tag (felis-api), a proxy jar filename +// (velocity) — and sysGatherer routes each component to the right one over an +// injected seam, all exercised with a fake runner (gatherer_test.go). The +// load-bearing case is proven: k3s's registry tag "v1.36.2-k3s1" is repaired to the +// "+k3s1" build form the binary reports (a Docker tag cannot hold '+'), so an image +// read and a CLI read agree instead of the image masquerading as a prerelease. The +// CLI seam (execRunner) is wired for real; only its exec I/O is un-verified here. // -// - REMAINING INTEGRATION (genuinely I/O-bound — needs a cluster/mailbox to exercise): -// the concrete Notifier (SMTP + in-game) and Applier (control-plane image bump, -// cloudflared swap), the `felis update` CLI + CronJob entry point, and the runtime -// append of the live Pinned Minecraft fleet. +// - REMAINING INTEGRATION (genuinely I/O-bound — needs a node/cluster/mailbox): the +// two current-version PRODUCING seams the gatherer still lacks — the k8s read of the +// control-plane Deployment's image (felis-api) and the off-cluster Velocity jar +// inspection, both left nil so those components surface a gather error rather than a +// wrong version — plus the concrete Notifier (SMTP + in-game) and Applier +// (control-plane image bump, cloudflared swap), the `felis update` CLI + CronJob +// entry point, and the runtime append of the live Pinned Minecraft fleet. package updater diff --git a/internal/updater/gatherer.go b/internal/updater/gatherer.go new file mode 100644 index 0000000..5a261a8 --- /dev/null +++ b/internal/updater/gatherer.go @@ -0,0 +1,194 @@ +package updater + +import ( + "context" + "fmt" + "regexp" + "strings" + + "felis.lolicon.best/internal/updates" +) + +// This file is the VERIFIABLE core of the current-version gatherer: the pure +// extractors that turn a raw system string (a `--version` line, a container image +// reference, a proxy jar filename) into an updates.Version, plus a sysGatherer that +// dispatches each component to the right extractor over an injected seam. The seams' +// actual I/O — shelling out, reading a running pod's image, listing an off-cluster +// jar — is integration and lives in gatherer_integration.go; here every path is +// exercised with a fake, exactly as the release sources are exercised with httptest. +// +// Why the extraction is load-bearing enough to unit-test: Current() feeds +// updates.Run's comparison. A mis-read current version is not a cosmetic bug — it +// silently makes every downstream decision wrong (a spurious apply, or a missed +// upgrade). The subtlety that proves the point is k3s: its Git tag "v1.36.2+k3s1" +// parses as a STABLE release (build metadata after '+' is ignored), but a container +// registry cannot store '+' in a tag, so the SAME build ships as the image tag +// "v1.36.2-k3s1" — which, taken literally, parses as a PRERELEASE ("-k3s1" tail) and +// would wrongly bar a stable image from comparison. versionFromImageRef normalizes +// that convention back; versionFromCLI never sees it because the k3s binary prints the +// '+' form. + +// commandRunner is the exec seam: it runs a binary and returns its combined output. +// The production implementation (execRunner, gatherer_integration.go) shells out to +// the real k3s/cloudflared binary; tests inject a fake that returns canned output, so +// the extraction logic is proven without either tool installed. +type commandRunner interface { + output(ctx context.Context, name string, args ...string) ([]byte, error) +} + +// versionFromCLI extracts a version from a ` --version` banner. It scans the +// whitespace-separated tokens and returns the FIRST that parses as a version, which +// matches the universal " version ()" convention while tolerating the +// differing preambles and trailing build/date fields: +// +// k3s: "k3s version v1.36.2+k3s1 (a1b2c3)\ngo version go1.24.0" -> v1.36.2+k3s1 +// cloudflared: "cloudflared version 2026.6.1 (built 2026-06-20-1057 UTC)" -> 2026.6.1 +// +// It fails closed: output with no parseable token is an error, never a zero version. +func versionFromCLI(raw string) (updates.Version, error) { + for _, tok := range strings.Fields(raw) { + if v, err := updates.Parse(tok); err == nil { + return v, nil + } + } + return updates.Version{}, fmt.Errorf("updater: no version token in CLI output %q", truncate(raw, 80)) +} + +// dockerBuildSuffix matches the k3s / rke2 build metadata that a container tag encodes +// with '-' because a Docker tag may not contain the SemVer '+'. Restoring the '+' +// makes the image tag parse to the same STABLE version the CLI reports. +var dockerBuildSuffix = regexp.MustCompile(`-(k3s\d+|rke2r\d+)$`) + +// versionFromImageRef extracts a version from a container image reference's tag: +// +// "rancher/k3s:v1.36.2-k3s1" -> v1.36.2 (stable; "-k3s1" restored to "+k3s1") +// "ghcr.io/acme/felis-api:1.4.0" -> 1.4.0 +// "localhost:5000/felis-api:1.4.0@sha256:deadbeef" -> 1.4.0 (registry port kept, digest stripped) +// +// It strips any "@sha256:" digest, takes the tag after the last ':' of the final path +// segment (so a "host:port/repo" registry port is not mistaken for the tag), restores +// the Docker-encoded k3s/rke2 build suffix, and parses. A reference with no tag or a +// non-version tag ("latest") fails closed. +func versionFromImageRef(ref string) (updates.Version, error) { + ref = strings.TrimSpace(ref) + if ref == "" { + return updates.Version{}, fmt.Errorf("updater: empty image reference") + } + if i := strings.IndexByte(ref, '@'); i >= 0 { // strip a "...@sha256:..." digest + ref = ref[:i] + } + // The tag, if any, is after the last ':' within the final path segment; a ':' in an + // earlier segment is a registry host port, not a tag separator. + lastSeg := ref[strings.LastIndexByte(ref, '/')+1:] + colon := strings.LastIndexByte(lastSeg, ':') + if colon < 0 { + return updates.Version{}, fmt.Errorf("updater: image reference %q has no tag", ref) + } + tag := lastSeg[colon+1:] + if tag == "" { + return updates.Version{}, fmt.Errorf("updater: image reference %q has an empty tag", ref) + } + tag = dockerBuildSuffix.ReplaceAllString(tag, "+$1") + v, err := updates.Parse(tag) + if err != nil { + return updates.Version{}, fmt.Errorf("updater: image tag: %w", err) + } + return v, nil +} + +// jarVersion matches the first dotted-numeric run in a filename (three parts preferred +// over two so "3.4.0" wins whole). +var jarVersion = regexp.MustCompile(`\d+\.\d+\.\d+|\d+\.\d+`) + +// versionFromJarName extracts a version from a proxy jar filename: +// +// "velocity-3.4.0-SNAPSHOT-461.jar" -> 3.4.0 +// "velocity-3.4.0.jar" -> 3.4.0 +// +// It is best-effort by nature (an admin may rename the jar): it returns the numeric +// core of the first version-looking token and fails closed if the name carries none. +// A "-SNAPSHOT" qualifier is intentionally dropped — Velocity is Notify-only and never +// auto-applied, so a slightly optimistic current only affects an advisory message. +func versionFromJarName(name string) (updates.Version, error) { + m := jarVersion.FindString(name) + if m == "" { + return updates.Version{}, fmt.Errorf("updater: no version in jar name %q", name) + } + v, err := updates.Parse(m) + if err != nil { + return updates.Version{}, fmt.Errorf("updater: jar name %q: %w", name, err) + } + return v, nil +} + +// sysGatherer is the production VersionGatherer. It reads each component's CURRENT +// version by the method that component exposes — a CLI banner for the node binaries +// (k3s, cloudflared), the running pod's image tag for the control plane (felis-api), +// the installed jar's name for the off-cluster proxy (velocity) — and turns it into a +// Version with the pure extractors above. The three seams are injected: `run` (exec) +// is wired in production; `imageForSpec` and `jarForSpec` are the two reads that still +// need real infra (a k8s client, off-cluster host access) and are nil until built, so +// those components surface a clear gather error rather than a wrong version. +// +// Dispatch is keyed by the component identities in Topology(); an unrecognized name is +// a loud error, not a silent skip. +type sysGatherer struct { + run commandRunner + imageForSpec func(ctx context.Context, spec Spec) (string, error) + jarForSpec func(ctx context.Context, spec Spec) (string, error) +} + +// Current implements VersionGatherer. +func (g sysGatherer) Current(ctx context.Context, spec Spec) (updates.Version, error) { + switch spec.Name { + case "k3s": + return g.cliVersion(ctx, "k3s") + case "cloudflared": + return g.cliVersion(ctx, "cloudflared") + case "felis-api": + if g.imageForSpec == nil { + return updates.Version{}, fmt.Errorf("updater: image gather seam for %q not wired", spec.Name) + } + ref, err := g.imageForSpec(ctx, spec) + if err != nil { + return updates.Version{}, fmt.Errorf("updater: read image for %q: %w", spec.Name, err) + } + return versionFromImageRef(ref) + case "velocity": + if g.jarForSpec == nil { + return updates.Version{}, fmt.Errorf("updater: jar gather seam for %q not wired", spec.Name) + } + name, err := g.jarForSpec(ctx, spec) + if err != nil { + return updates.Version{}, fmt.Errorf("updater: read jar for %q: %w", spec.Name, err) + } + return versionFromJarName(name) + default: + return updates.Version{}, fmt.Errorf("updater: no gather method for component %q", spec.Name) + } +} + +// cliVersion runs ` --version` through the exec seam and extracts the version. +func (g sysGatherer) cliVersion(ctx context.Context, bin string) (updates.Version, error) { + if g.run == nil { + return updates.Version{}, fmt.Errorf("updater: command runner not wired for %q", bin) + } + out, err := g.run.output(ctx, bin, "--version") + if err != nil { + return updates.Version{}, fmt.Errorf("updater: run %s --version: %w", bin, err) + } + v, err := versionFromCLI(string(out)) + if err != nil { + return updates.Version{}, fmt.Errorf("updater: %s: %w", bin, err) + } + return v, nil +} + +// truncate bounds an error's echo of untrusted output. +func truncate(s string, n int) string { + s = strings.TrimSpace(s) + if len(s) <= n { + return s + } + return s[:n] + "…" +} diff --git a/internal/updater/gatherer_integration.go b/internal/updater/gatherer_integration.go new file mode 100644 index 0000000..c199788 --- /dev/null +++ b/internal/updater/gatherer_integration.go @@ -0,0 +1,35 @@ +package updater + +import ( + "context" + "os/exec" +) + +// This file is INTEGRATION-ONLY. execRunner shells out to the real k3s / cloudflared +// binaries; it cannot run on a box without them and is never exercised by the unit +// tests — the extraction logic it feeds (gatherer.go) is proven with a fake +// commandRunner. This mirrors internal/cfsetup, which keeps its ExecRunner apart from +// its unit-verified core. + +// execRunner is the production commandRunner: it runs the tool and returns combined +// output (banners sometimes print to stderr), bounded by the caller's context. +type execRunner struct{} + +func (execRunner) output(ctx context.Context, name string, args ...string) ([]byte, error) { + return exec.CommandContext(ctx, name, args...).CombinedOutput() +} + +// NewSysGatherer builds the production VersionGatherer. The CLI seam (k3s / cloudflared +// `--version`) is wired and works on a real node. The felis-api pod-image read and the +// off-cluster Velocity jar inspection are the remaining integration seams; they are +// deliberately left nil, so those two components surface an explicit "gather seam not +// wired" error (which the Runner records and skips) rather than being planned against a +// wrong or zero version. Wiring them — a k8s client read of the control-plane +// Deployment's image, and however the operator exposes the proxy host — is the next +// integration step, tracked in doc.go. When the jar seam lands, revisit +// versionFromJarName's "-SNAPSHOT" handling: it reports a snapshot build as its stable +// core, which is harmless while this seam is nil and Velocity is Notify-only, but would +// suppress a legitimate "a stable is now out" notice once a real current flows. +func NewSysGatherer() VersionGatherer { + return sysGatherer{run: execRunner{}} +} diff --git a/internal/updater/gatherer_test.go b/internal/updater/gatherer_test.go new file mode 100644 index 0000000..960337e --- /dev/null +++ b/internal/updater/gatherer_test.go @@ -0,0 +1,214 @@ +package updater + +import ( + "context" + "errors" + "fmt" + "testing" +) + +// fakeCmd is a fake commandRunner: it returns canned `--version` output keyed by the +// binary name, or a fixed error, so the CLI extraction path is proven without k3s or +// cloudflared installed. +type fakeCmd struct { + out map[string][]byte + err error +} + +func (f fakeCmd) output(_ context.Context, name string, _ ...string) ([]byte, error) { + if f.err != nil { + return nil, f.err + } + b, ok := f.out[name] + if !ok { + return nil, fmt.Errorf("no canned output for %q", name) + } + return b, nil +} + +// Real `--version` banners (captured from the tools' known output shape). The k3s +// banner is the important one: the binary prints the '+k3s1' build form, which must +// parse STABLE — contrast versionFromImageRef, which has to repair the '-k3s1' the +// registry forces. +const ( + k3sVersionBanner = "k3s version v1.36.2+k3s1 (a1b2c3d4)\ngo version go1.24.0\n" + cloudflaredVersionBanner = "cloudflared version 2026.6.1 (built 2026-06-20-1057 UTC)\n" +) + +func TestVersionFromCLI(t *testing.T) { + cases := []struct { + name string + raw string + wantCore [3]int + wantStr string + }{ + {"k3s keeps +build stable", k3sVersionBanner, [3]int{1, 36, 2}, "v1.36.2+k3s1"}, + {"cloudflared calver", cloudflaredVersionBanner, [3]int{2026, 6, 1}, "2026.6.1"}, + {"version after a label", "Version: 1.2.3", [3]int{1, 2, 3}, "1.2.3"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + v, err := versionFromCLI(tc.raw) + if err != nil { + t.Fatalf("versionFromCLI(%q): %v", tc.raw, err) + } + if got := [3]int{v.Major, v.Minor, v.Patch}; got != tc.wantCore { + t.Errorf("core = %v, want %v", got, tc.wantCore) + } + if v.IsPrerelease() { + t.Errorf("%q parsed as prerelease", tc.raw) + } + if v.String() != tc.wantStr { + t.Errorf("String() = %q, want %q", v.String(), tc.wantStr) + } + }) + } + + for _, bad := range []string{"", " ", "no version in here at all", "k3s\ngo version go1.24"} { + if v, err := versionFromCLI(bad); err == nil { + t.Errorf("versionFromCLI(%q) = %q, want error", bad, v.String()) + } + } +} + +func TestVersionFromImageRef(t *testing.T) { + cases := []struct { + name string + ref string + wantCore [3]int + }{ + {"k3s docker tag restored to stable", "rancher/k3s:v1.36.2-k3s1", [3]int{1, 36, 2}}, + {"rke2 docker tag restored to stable", "rancher/rke2-runtime:v1.31.4-rke2r1", [3]int{1, 31, 4}}, + {"clean semver with registry", "ghcr.io/acme/felis-api:1.4.0", [3]int{1, 4, 0}}, + {"registry port kept, digest stripped", "localhost:5000/felis-api:1.4.0@sha256:deadbeef", [3]int{1, 4, 0}}, + {"cloudflared calver image", "cloudflare/cloudflared:2026.6.1", [3]int{2026, 6, 1}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + v, err := versionFromImageRef(tc.ref) + if err != nil { + t.Fatalf("versionFromImageRef(%q): %v", tc.ref, err) + } + if got := [3]int{v.Major, v.Minor, v.Patch}; got != tc.wantCore { + t.Errorf("core = %v, want %v", got, tc.wantCore) + } + if v.IsPrerelease() { + t.Errorf("%q parsed as prerelease — the '-k3s1'/'-rke2r1' build suffix was not restored to '+'", tc.ref) + } + }) + } + + // No tag, empty tag, non-version tag, and empty ref all fail closed. + for _, bad := range []string{"", "ubuntu", "ghcr.io/acme/felis-api", "repo:latest", "repo:", "repo:latest@sha256:abc"} { + if v, err := versionFromImageRef(bad); err == nil { + t.Errorf("versionFromImageRef(%q) = %q, want error", bad, v.String()) + } + } +} + +func TestVersionFromJarName(t *testing.T) { + cases := []struct { + name string + jar string + wantCore [3]int + }{ + {"snapshot build, qualifier dropped", "velocity-3.4.0-SNAPSHOT-461.jar", [3]int{3, 4, 0}}, + {"plain release jar", "velocity-3.4.0.jar", [3]int{3, 4, 0}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + v, err := versionFromJarName(tc.jar) + if err != nil { + t.Fatalf("versionFromJarName(%q): %v", tc.jar, err) + } + if got := [3]int{v.Major, v.Minor, v.Patch}; got != tc.wantCore { + t.Errorf("core = %v, want %v", got, tc.wantCore) + } + if v.IsPrerelease() { + t.Errorf("%q parsed as prerelease — the numeric core should be taken bare", tc.jar) + } + }) + } + + for _, bad := range []string{"", "velocity.jar", "proxy-latest.jar"} { + if v, err := versionFromJarName(bad); err == nil { + t.Errorf("versionFromJarName(%q) = %q, want error", bad, v.String()) + } + } +} + +// fullFakeGatherer wires every seam with a fake so Current() can be driven for all four +// real Topology components without a node, a cluster, or the proxy host. +func fullFakeGatherer() sysGatherer { + return sysGatherer{ + run: fakeCmd{out: map[string][]byte{ + "k3s": []byte(k3sVersionBanner), + "cloudflared": []byte(cloudflaredVersionBanner), + }}, + imageForSpec: func(_ context.Context, _ Spec) (string, error) { return "ghcr.io/acme/felis-api:1.4.0", nil }, + jarForSpec: func(_ context.Context, _ Spec) (string, error) { return "velocity-3.4.0-SNAPSHOT-461.jar", nil }, + } +} + +// TestSysGathererCurrentDispatch drives Current() for every component the real Topology +// tracks, proving each name routes to the right seam+extractor and the discovered +// current version (including the raw tag a report needs) comes back correctly. +func TestSysGathererCurrentDispatch(t *testing.T) { + g := fullFakeGatherer() + want := map[string]struct { + core [3]int + str string + }{ + "k3s": {[3]int{1, 36, 2}, "v1.36.2+k3s1"}, + "cloudflared": {[3]int{2026, 6, 1}, "2026.6.1"}, + "felis-api": {[3]int{1, 4, 0}, "1.4.0"}, + "velocity": {[3]int{3, 4, 0}, "3.4.0"}, + } + for _, spec := range Topology() { + w, ok := want[spec.Name] + if !ok { + t.Fatalf("Topology grew a component %q with no gather expectation — update this test", spec.Name) + } + v, err := g.Current(context.Background(), spec) + if err != nil { + t.Errorf("Current(%s): %v", spec.Name, err) + continue + } + if got := [3]int{v.Major, v.Minor, v.Patch}; got != w.core { + t.Errorf("Current(%s) core = %v, want %v", spec.Name, got, w.core) + } + if v.String() != w.str { + t.Errorf("Current(%s) String() = %q, want %q", spec.Name, v.String(), w.str) + } + } +} + +// TestSysGathererFailsClosed covers every way a gather can fail: a runner error, a seam +// that is not wired (the production default for felis-api/velocity), and an unknown +// component. None returns a usable zero version. +func TestSysGathererFailsClosed(t *testing.T) { + k3s := Spec{Name: "k3s"} + felis := Spec{Name: "felis-api"} + velo := Spec{Name: "velocity"} + + // Runner error propagates. + boom := sysGatherer{run: fakeCmd{err: errors.New("exec: k3s not found")}} + if _, err := boom.Current(context.Background(), k3s); err == nil { + t.Error("a runner error should fail the gather") + } + + // The production constructor leaves the image/jar seams nil: those components must + // report an explicit not-wired error, not a wrong version. + prod := NewSysGatherer() + if _, err := prod.Current(context.Background(), felis); err == nil { + t.Error("felis-api gather should error while the image seam is unwired") + } + if _, err := prod.Current(context.Background(), velo); err == nil { + t.Error("velocity gather should error while the jar seam is unwired") + } + + // An unknown component is a loud error, never a silent skip. + if _, err := prod.Current(context.Background(), Spec{Name: "postgres"}); err == nil { + t.Error("an unrecognized component should error") + } +} diff --git a/internal/updater/topology.go b/internal/updater/topology.go index 400e109..ff81a5b 100644 --- a/internal/updater/topology.go +++ b/internal/updater/topology.go @@ -47,8 +47,9 @@ type Spec struct { // pin — there is no policy path by which Topology can propose changing it. func Topology() []Spec { return []Spec{ - // Coord "felis/felis" is a placeholder for the operator's own release repo; the - // GitHub source (which would consume it) is not yet wired, so it is inert today. + // Coord "felis/felis" is a placeholder for the operator's own release repo: the + // GitHub source now consumes it, so the routing/parse path is live, but it will + // not resolve against real GitHub until the operator's actual repo slug is set. {Name: "felis-api", Policy: updates.PolicyScheduled, Manageable: true, Source: sourceGitHub, Coord: "felis/felis"}, {Name: "k3s", Policy: updates.PolicyNotify, Manageable: false, Source: sourceGitHub, Coord: "k3s-io/k3s"}, {Name: "cloudflared", Policy: updates.PolicyScheduled, Manageable: true, Source: sourceGitHub, Coord: "cloudflare/cloudflared"},