diff --git a/Taskfile.yml b/Taskfile.yml index a8090d8..e20b5fe 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -7,18 +7,12 @@ tasks: - task --list build: - desc: Build datumctl locally with go, embedding a real + version + desc: Build datumctl locally with go (Go stamps a pseudo-version such as v0.23.4-0.- after the last tag) vars: - LAST_TAG: - sh: git describe --tags --abbrev=0 2>/dev/null || echo v0.0.0 - GIT_SHA: - sh: git rev-parse --short=8 HEAD GIT_COMMIT: sh: git rev-parse HEAD - DIRTY_SUFFIX: - sh: (git diff --quiet && git diff --cached --quiet) || echo -dev cmds: - - go build -ldflags "-X k8s.io/component-base/version.gitVersion={{.LAST_TAG}}+{{.GIT_SHA}}{{.DIRTY_SUFFIX}} -X k8s.io/component-base/version.gitCommit={{.GIT_COMMIT}}" -o datumctl . + - go build -ldflags "-X k8s.io/component-base/version.gitCommit={{.GIT_COMMIT}}" -o datumctl . nix-update-hash: desc: Automatically update the vendorHash in flake.nix after Go dependency changes @@ -29,11 +23,13 @@ tasks: desc: Build the project with Nix cmds: # `nix build` only sees files git knows about (even untracked ones must be - # staged), so drop the last tag in a VERSION file and stage it with - # intent-to-add; flake.nix reads it to embed + in the - # binary. Cleaned up afterwards so it's never actually committed. + # staged), so write `git describe` to a VERSION file and stage it with + # intent-to-add; flake.nix reads it to embed the same pseudo-version + # `go build` would. --dirty is captured before VERSION exists, since + # staging it makes nix see the tree as dirty. Cleaned up afterwards so + # it's never actually committed. - defer: git reset -- VERSION && rm -f VERSION - - git describe --tags --abbrev=0 > VERSION + - git describe --tags --long --dirty > VERSION || true - git add -N VERSION - nix build diff --git a/flake.nix b/flake.nix index 68936c9..b11bdd7 100644 --- a/flake.nix +++ b/flake.nix @@ -18,29 +18,53 @@ inherit system; }; - # Nix flakes always strip .git from the copied source (self/./. here has - # no .git even though the real checkout does), so `self.rev`/`dirtyRev` - # -- populated by the flake's git-fetcher from outside that copy -- are - # the only reliable way to get the commit sha. There's no equivalent - # flake attribute for "nearest git tag", so that comes from an optional - # untracked VERSION file (`git describe --tags --abbrev=0 > VERSION`, - # not committed) which -- being an ordinary file -- does survive the - # source copy; absent that file this falls back to a valid placeholder. - # Either way the result must be semver-shaped ("vX.Y.Z[+meta]"), since - # k8s.io/component-base/version (used by `datumctl version`) parses it - # strictly and a bare "dev"/"unknown" would fail that parse. - # self.dirtyRev/dirtyShortRev append a literal "-dirty" suffix when the - # working tree has uncommitted changes; swap it for "-dev" everywhere. - markDev = builtins.replaceStrings [ "-dirty" ] [ "-dev" ]; - gitCommit = markDev (self.dirtyRev or self.rev or "unknown"); - gitSha = markDev (self.dirtyShortRev or self.shortRev or "unknown"); - - lastTag = - if (builtins.pathExists ./VERSION) - then builtins.replaceStrings ["\n"] [""] (builtins.readFile ./VERSION) - else "v0.0.0"; - - gitVersion = "${lastTag}+${gitSha}"; + # Nix flakes always strip .git from the copied source, so the commit + # comes from self.rev/self.dirtyRev and its timestamp from + # self.lastModifiedDate (UTC, YYYYMMDDHHMMSS). There's no flake + # attribute for the nearest tag, so `task nix-build` writes + # `git describe --tags --long --dirty` (e.g. "v0.23.3-38-g818025e") + # to an untracked VERSION file, which survives the source copy. + # Staging that file makes nix see every tree as dirty, so when it + # exists its "-dirty" suffix decides dirtiness instead. + # + # From those this builds the same version a plain `go build` stamps: + # at a tag: v0.23.3 + # after a tag: v0.23.4-0.20261103201002-818025e9fe34 + # no VERSION file: v0.0.0-20261103201002-818025e9fe34 + # i.e. a Go pseudo-version, which sorts after the last release and + # before the next, so it can't be mistaken for a release. A dirty + # tree appends "+dev". + lib = pkgs.lib; + hasVersionFile = builtins.pathExists ./VERSION; + describeRaw = if hasVersionFile then lib.trim (builtins.readFile ./VERSION) else ""; + describe = lib.removeSuffix "-dirty" describeRaw; + dirty = if hasVersionFile then lib.hasSuffix "-dirty" describeRaw else self ? dirtyRev; + rev = self.rev or (lib.removeSuffix "-dirty" (self.dirtyRev or "")); + gitCommit = if rev == "" then "unknown" else rev; + gitTreeState = if dirty then "dirty" else "clean"; + # Commit time as RFC 3339, matching what a plain `go build` reports. + d = self.lastModifiedDate; + buildDate = "${builtins.substring 0 4 d}-${builtins.substring 4 2 d}-${builtins.substring 6 2 d}T${builtins.substring 8 2 d}:${builtins.substring 10 2 d}:${builtins.substring 12 2 d}Z"; + pseudoSuffix = "${self.lastModifiedDate}-${builtins.substring 0 12 rev}"; + + # [ tag commitsSinceTag ], or null for an empty or unrecognized VERSION. + described = builtins.match "(v[0-9]+\\.[0-9]+\\.[0-9]+.*)-([0-9]+)-g[0-9a-f]+" describe; + # [ major minor patch prerelease-or-null ] + tagParts = builtins.match "v([0-9]+)\\.([0-9]+)\\.([0-9]+)(-.*)?" (builtins.elemAt described 0); + + pseudoVersion = + if described == null || tagParts == null then "v0.0.0-${pseudoSuffix}" + else if builtins.elemAt described 1 == "0" then builtins.elemAt described 0 + else if builtins.elemAt tagParts 3 != null then "${builtins.elemAt described 0}.0.${pseudoSuffix}" + else + let + patch = lib.toInt (builtins.elemAt tagParts 2) + 1; + in + "v${builtins.elemAt tagParts 0}.${builtins.elemAt tagParts 1}.${toString patch}-0.${pseudoSuffix}"; + + gitVersion = + if rev == "" then "v0.0.0" + else pseudoVersion + lib.optionalString dirty "+dev"; in { @@ -65,6 +89,8 @@ "-w" "-X k8s.io/component-base/version.gitVersion=${gitVersion}" "-X k8s.io/component-base/version.gitCommit=${gitCommit}" + "-X k8s.io/component-base/version.gitTreeState=${gitTreeState}" + "-X k8s.io/component-base/version.buildDate=${buildDate}" "-extldflags=-static" ]; diff --git a/internal/cmd/autoupdate/autoupdate.go b/internal/cmd/autoupdate/autoupdate.go index 2cca30a..e1c8308 100644 --- a/internal/cmd/autoupdate/autoupdate.go +++ b/internal/cmd/autoupdate/autoupdate.go @@ -9,11 +9,11 @@ import ( "time" "github.com/spf13/cobra" - componentversion "k8s.io/component-base/version" "go.datum.net/datumctl/internal/datumconfig" customerrors "go.datum.net/datumctl/internal/errors" "go.datum.net/datumctl/internal/updatecheck" + datumversion "go.datum.net/datumctl/internal/version" ) // Command returns the "auto-update" command tree. @@ -46,7 +46,7 @@ arguments performs an upgrade immediately, regardless of that setting.`, } func runUpdate(cmd *cobra.Command, _ []string) error { - current := componentversion.Get().GitVersion + current := datumversion.Get() ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second) defer cancel() diff --git a/internal/cmd/plugin/browse.go b/internal/cmd/plugin/browse.go index 8206f1e..97df830 100644 --- a/internal/cmd/plugin/browse.go +++ b/internal/cmd/plugin/browse.go @@ -8,11 +8,11 @@ import ( "charm.land/huh/v2" "github.com/spf13/cobra" "golang.org/x/term" - componentversion "k8s.io/component-base/version" "k8s.io/kubectl/pkg/util/templates" customerrors "go.datum.net/datumctl/internal/errors" "go.datum.net/datumctl/internal/pluginstore" + datumversion "go.datum.net/datumctl/internal/version" ) // browseEntry is one selectable plugin in the interactive browser, paired with @@ -98,7 +98,7 @@ func browseCmd() *cobra.Command { return nil } - currentVersion := componentversion.Get().GitVersion + currentVersion := datumversion.Get() // Empty version installs the catalog's recommended release. return installFromCatalog(cmd, pluginsDir, reg, selected.catalog.Name, selected.plugin.Name, "", currentVersion) }, diff --git a/internal/cmd/plugin/install.go b/internal/cmd/plugin/install.go index dc7b3a9..b39f30d 100644 --- a/internal/cmd/plugin/install.go +++ b/internal/cmd/plugin/install.go @@ -9,12 +9,12 @@ import ( "time" "github.com/spf13/cobra" - componentversion "k8s.io/component-base/version" "go.datum.net/datumctl/internal/client" customerrors "go.datum.net/datumctl/internal/errors" "go.datum.net/datumctl/internal/plugindispatch" "go.datum.net/datumctl/internal/pluginstore" + datumversion "go.datum.net/datumctl/internal/version" ) func installCmd(_ *client.DatumCloudFactory) *cobra.Command { @@ -86,7 +86,7 @@ The plugin binary is written to the managed plugins directory return err } - currentVersion := componentversion.Get().GitVersion + currentVersion := datumversion.Get() if allAvailable { if len(args) > 0 { diff --git a/internal/cmd/plugin/upgrade.go b/internal/cmd/plugin/upgrade.go index 6472f10..49ddfc0 100644 --- a/internal/cmd/plugin/upgrade.go +++ b/internal/cmd/plugin/upgrade.go @@ -8,12 +8,12 @@ import ( "strings" "github.com/spf13/cobra" - componentversion "k8s.io/component-base/version" "k8s.io/kubectl/pkg/util/templates" "go.datum.net/datumctl/internal/client" customerrors "go.datum.net/datumctl/internal/errors" "go.datum.net/datumctl/internal/pluginstore" + datumversion "go.datum.net/datumctl/internal/version" ) func upgradeCmd(_ *client.DatumCloudFactory) *cobra.Command { @@ -58,7 +58,7 @@ func upgradeCmd(_ *client.DatumCloudFactory) *cobra.Command { return fmt.Errorf("load plugins manifest: %w", err) } - currentVersion := componentversion.Get().GitVersion + currentVersion := datumversion.Get() if all { return upgradeAll(cmd, pluginsDir, manifest, currentVersion) diff --git a/internal/cmd/plugin_suggest.go b/internal/cmd/plugin_suggest.go index 67ec958..813f588 100644 --- a/internal/cmd/plugin_suggest.go +++ b/internal/cmd/plugin_suggest.go @@ -8,12 +8,12 @@ import ( "github.com/spf13/cobra" "golang.org/x/term" - componentversion "k8s.io/component-base/version" plugincmd "go.datum.net/datumctl/internal/cmd/plugin" "go.datum.net/datumctl/internal/client" "go.datum.net/datumctl/internal/plugindispatch" "go.datum.net/datumctl/internal/pluginstore" + datumversion "go.datum.net/datumctl/internal/version" ) // suggestAndInstallPlugin checks the local plugin index for an exact match on @@ -60,7 +60,7 @@ func suggestAndInstallPlugin(cmd *cobra.Command, name, pluginsDir string, origin } fmt.Fprintf(cmd.ErrOrStderr(), "\nInstalling %s...\n", entry.Name) - currentVersion := componentversion.Get().GitVersion + currentVersion := datumversion.Get() installed, _, installErr := plugincmd.InstallPlugin(cmd.Context(), pluginsDir, entry.Name, "", currentVersion, idx) if installErr != nil { return fmt.Errorf("install plugin: %w", installErr) diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 008dea9..39e0450 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -14,7 +14,6 @@ import ( "k8s.io/cli-runtime/pkg/genericclioptions" "k8s.io/client-go/rest" "k8s.io/client-go/transport" - componentversion "k8s.io/component-base/version" "k8s.io/kubectl/pkg/cmd/annotate" "k8s.io/kubectl/pkg/cmd/apiresources" "k8s.io/kubectl/pkg/cmd/apply" @@ -48,6 +47,7 @@ import ( "go.datum.net/datumctl/internal/plugindispatch" "go.datum.net/datumctl/internal/pluginstore" "go.datum.net/datumctl/internal/updatecheck" + datumversion "go.datum.net/datumctl/internal/version" ) // hideFlags hides the named flags from a command's flag set. Flags that do not @@ -200,7 +200,7 @@ are unaffected.`, manifest, loadErr := pluginstore.Load(pluginsDir) if loadErr == nil && manifest.Plugins != nil { if entry, ok := manifest.Plugins[name]; ok && entry.Manifest != nil { - currentVersion := componentversion.Get().GitVersion + currentVersion := datumversion.Get() if warn, compatErr := plugindispatch.CheckCompatibilityAtInvocation(entry.Manifest, currentVersion, plugindispatch.PluginAPIVersion); compatErr != nil { return customerrors.NewUserError(compatErr.Error()) } else if warn != "" { @@ -830,6 +830,10 @@ the server.` } return nil } + versionCmd.Run = nil + versionCmd.RunE = func(cmd *cobra.Command, args []string) error { + return runVersion(factory, ioStreams.Out, cmd) + } versionCmd.GroupID = "other" rootCmd.AddCommand(versionCmd) @@ -989,7 +993,7 @@ func startUpdateCheck(cmd *cobra.Command) { if updatecheck.SkipFromEnvironment() { return } - current := componentversion.Get().GitVersion + current := datumversion.Get() checker := updatecheck.New(current) checker.Start(cmd.Context()) activeUpdateChecker[cmd.Root()] = checker diff --git a/internal/cmd/version.go b/internal/cmd/version.go new file mode 100644 index 0000000..c7af50e --- /dev/null +++ b/internal/cmd/version.go @@ -0,0 +1,73 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "io" + + "github.com/spf13/cobra" + "k8s.io/client-go/tools/clientcmd" + cmdutil "k8s.io/kubectl/pkg/cmd/util" + "k8s.io/kubectl/pkg/cmd/version" + "sigs.k8s.io/yaml" + + datumversion "go.datum.net/datumctl/internal/version" +) + +// runVersion replaces kubectl's version Run. kubectl reads the client +// version straight from k8s.io/component-base/version, which can't hold +// the pseudo-version datumversion.Get resolves for unreleased builds. It +// also skips kubectl's client/server skew check: datumctl's version and +// the Datum Cloud API server's version are independent, so comparing them +// is meaningless, and the check fails outright on non-semver server +// versions. +func runVersion(f cmdutil.Factory, out io.Writer, cmd *cobra.Command) error { + clientOnly, _ := cmd.Flags().GetBool("client") + output, _ := cmd.Flags().GetString("output") + if output != "" && output != "yaml" && output != "json" { + return fmt.Errorf("--output must be 'yaml' or 'json'") + } + + clientInfo := datumversion.Info() + info := version.Version{ClientVersion: &clientInfo} + if kv, ok := version.GetKustomizeModVersion(); ok { + info.KustomizeVersion = kv + } + + var serverErr error + if !clientOnly { + dc, err := f.ToDiscoveryClient() + // An empty config means no context is set up; print client info only. + if err != nil && !clientcmd.IsEmptyConfig(err) { + return err + } + if dc != nil { + dc.Invalidate() + info.ServerVersion, serverErr = dc.ServerVersion() + } + } + + switch output { + case "": + fmt.Fprintf(out, "Client Version: %s\n", info.ClientVersion.GitVersion) + if info.KustomizeVersion != "" { + fmt.Fprintf(out, "Kustomize Version: %s\n", info.KustomizeVersion) + } + if info.ServerVersion != nil { + fmt.Fprintf(out, "Server Version: %s\n", info.ServerVersion.GitVersion) + } + case "yaml": + b, err := yaml.Marshal(&info) + if err != nil { + return err + } + fmt.Fprintln(out, string(b)) + case "json": + b, err := json.MarshalIndent(&info, "", " ") + if err != nil { + return err + } + fmt.Fprintln(out, string(b)) + } + return serverErr +} diff --git a/internal/version/version.go b/internal/version/version.go index 3c83a04..a827ec0 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -1,22 +1,26 @@ -// Package version derives a fallback for k8s.io/component-base/version's -// GitVersion when datumctl is built without the ldflags that release, nix, -// and `task build` all inject (e.g. a bare `go run .` or `go test`). Go's -// toolchain stamps VCS info into the binary automatically from the module's -// git checkout, so this uses that instead of leaving component-base's -// compiled-in placeholder, which fails to parse in places that expect a real -// semantic version (plugin compatibility checks, the update checker). +// Package version resolves datumctl's own version string. // -// The fallback can only ever be "v0.0.0+[-dev]", never a real tag: -// component-base's SetDynamicVersion rejects any version whose major/minor/ -// patch don't match the compiled-in placeholder's (0.0.0), and Go's VCS -// stamping has no notion of the nearest tag anyway. Getting the real last -// tag requires setting GitVersion via ldflags at compile time instead, which -// is what `task build` (and nix/goreleaser) do. +// Release, nix, and goreleaser builds inject a version into +// k8s.io/component-base/version via ldflags. Any other build (go build, +// go run, go install …@main) gets no ldflags, but the Go toolchain stamps +// a module pseudo-version derived from the nearest tag into the binary +// (e.g. "v0.23.4-0.20261103201002-818025e9fe34"). That pseudo-version is +// what Get returns in that case: it is based on the last release, carries +// the commit, and sorts after that release and before the next one, so it +// can never be mistaken for a release. +// +// component-base can't carry that string itself: its SetDynamicVersion +// rejects any version whose major/minor/patch differ from the compiled-in +// placeholder's (0.0.0). Code that needs datumctl's version must call Get +// instead of reading component-base directly. package version import ( "runtime/debug" + "strings" + "golang.org/x/mod/semver" + apimachineryversion "k8s.io/apimachinery/pkg/version" componentversion "k8s.io/component-base/version" ) @@ -24,15 +28,85 @@ import ( // present whenever no ldflags have set a real GitVersion. const placeholder = "v0.0.0-master+$Format:%H$" -// ApplyFallback installs a fallback GitVersion derived from the binary's -// embedded VCS info, unless ldflags have already set a real one or no VCS -// info was stamped into the binary (e.g. building outside a git checkout). +// component-base's compiled-in defaults for the other Info fields. +const ( + commitPlaceholder = "$Format:%H$" + buildDatePlaceholder = "1970-01-01T00:00:00Z" +) + +// resolved is computed at package init, before ApplyFallback can replace +// component-base's placeholder, so Get can tell whether ldflags set it. +var resolved = resolve(componentversion.Get().GitVersion, readBuildInfo()) + +func readBuildInfo() *debug.BuildInfo { + info, ok := debug.ReadBuildInfo() + if !ok { + return nil + } + return info +} + +// Get returns datumctl's version: the ldflags-injected version if any, +// otherwise the Go module pseudo-version stamped by the toolchain, +// otherwise "v0.0.0+[-dev]". +func Get() string { + return resolved +} + +// Info returns component-base's version info with GitVersion set to Get +// and any field ldflags left at its compiled-in default filled from the +// VCS info the Go toolchain stamps into the binary. BuildDate gets the +// commit time, as reproducible builds (SOURCE_DATE_EPOCH) do. +func Info() apimachineryversion.Info { + return completeInfo(componentversion.Get(), resolved, readBuildInfo()) +} + +func completeInfo(info apimachineryversion.Info, gitVersion string, bi *debug.BuildInfo) apimachineryversion.Info { + info.GitVersion = gitVersion + if semver.IsValid(gitVersion) { + mm := strings.SplitN(strings.TrimPrefix(semver.MajorMinor(gitVersion), "v"), ".", 2) + if info.Major == "" { + info.Major = mm[0] + } + if info.Minor == "" { + info.Minor = mm[1] + } + } + if bi == nil { + return info + } + for _, setting := range bi.Settings { + switch setting.Key { + case "vcs.revision": + if info.GitCommit == commitPlaceholder || info.GitCommit == "" { + info.GitCommit = setting.Value + } + case "vcs.time": + if info.BuildDate == buildDatePlaceholder || info.BuildDate == "" { + info.BuildDate = setting.Value + } + case "vcs.modified": + if info.GitTreeState == "" { + info.GitTreeState = "clean" + if setting.Value == "true" { + info.GitTreeState = "dirty" + } + } + } + } + return info +} + +// ApplyFallback replaces component-base's unparseable placeholder with a +// valid "v0.0.0+[-dev]" so kubectl internals that read it (e.g. the +// User-Agent) see a well-formed version. It can't install the value Get +// returns; see the package doc. func ApplyFallback() { if componentversion.Get().GitVersion != placeholder { return } - info, ok := debug.ReadBuildInfo() - if !ok { + info := readBuildInfo() + if info == nil { return } if fallback, ok := fallbackFromBuildInfo(info); ok { @@ -40,6 +114,36 @@ func ApplyFallback() { } } +func resolve(ldflagsVersion string, info *debug.BuildInfo) string { + if ldflagsVersion != placeholder { + return ldflagsVersion + } + if info == nil { + return ldflagsVersion + } + if v, ok := moduleVersion(info); ok { + return v + } + if v, ok := fallbackFromBuildInfo(info); ok { + return v + } + return ldflagsVersion +} + +// moduleVersion returns the main module version the Go toolchain stamped +// into the binary, with Go's "+dirty" marker rewritten to "+dev". It +// reports false for "(devel)", which Go uses when there's no VCS info. +func moduleVersion(info *debug.BuildInfo) (string, bool) { + v := info.Main.Version + if v == "" || v == "(devel)" { + return "", false + } + if base, ok := strings.CutSuffix(v, "+dirty"); ok { + v = base + "+dev" + } + return v, true +} + func fallbackFromBuildInfo(info *debug.BuildInfo) (string, bool) { var revision string var dirty bool diff --git a/internal/version/version_test.go b/internal/version/version_test.go index d99c560..7555a61 100644 --- a/internal/version/version_test.go +++ b/internal/version/version_test.go @@ -3,6 +3,8 @@ package version import ( "runtime/debug" "testing" + + apimachineryversion "k8s.io/apimachinery/pkg/version" ) func TestFallbackFromBuildInfo(t *testing.T) { @@ -56,3 +58,97 @@ func TestFallbackFromBuildInfo(t *testing.T) { }) } } + +func TestResolve(t *testing.T) { + sha := []debug.BuildSetting{{Key: "vcs.revision", Value: "818025e9fe34abcdef0123456789abcdef012345"}} + cases := []struct { + name string + ldflags string + info *debug.BuildInfo + want string + }{ + { + name: "ldflags win", + ldflags: "v0.23.4-0.20261103201002-818025e9fe34", + info: &debug.BuildInfo{Main: debug.Module{Version: "(devel)"}}, + want: "v0.23.4-0.20261103201002-818025e9fe34", + }, + { + name: "pseudo-version from toolchain", + ldflags: placeholder, + info: &debug.BuildInfo{Main: debug.Module{Version: "v0.23.4-0.20261103201002-818025e9fe34"}, Settings: sha}, + want: "v0.23.4-0.20261103201002-818025e9fe34", + }, + { + name: "dirty pseudo-version marked dev", + ldflags: placeholder, + info: &debug.BuildInfo{Main: debug.Module{Version: "v0.23.4-0.20261103201002-818025e9fe34+dirty"}, Settings: sha}, + want: "v0.23.4-0.20261103201002-818025e9fe34+dev", + }, + { + name: "tagged release build", + ldflags: placeholder, + info: &debug.BuildInfo{Main: debug.Module{Version: "v0.23.3"}, Settings: sha}, + want: "v0.23.3", + }, + { + name: "devel falls back to sha", + ldflags: placeholder, + info: &debug.BuildInfo{Main: debug.Module{Version: "(devel)"}, Settings: sha}, + want: "v0.0.0+818025e9", + }, + { + name: "no build info", + ldflags: placeholder, + info: nil, + want: placeholder, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := resolve(tc.ldflags, tc.info); got != tc.want { + t.Fatalf("resolve = %q, want %q", got, tc.want) + } + }) + } +} + +func TestCompleteInfo(t *testing.T) { + defaults := apimachineryversion.Info{GitCommit: commitPlaceholder, BuildDate: buildDatePlaceholder, GoVersion: "go1.27.3"} + vcs := &debug.BuildInfo{Settings: []debug.BuildSetting{ + {Key: "vcs.revision", Value: "818025e9fe34abcdef0123456789abcdef012345"}, + {Key: "vcs.time", Value: "2026-11-03T20:10:02Z"}, + {Key: "vcs.modified", Value: "true"}, + }} + + t.Run("fills defaults from vcs info", func(t *testing.T) { + got := completeInfo(defaults, "v0.23.4-0.20261103201002-818025e9fe34+dev", vcs) + want := apimachineryversion.Info{ + Major: "0", Minor: "23", + GitVersion: "v0.23.4-0.20261103201002-818025e9fe34+dev", + GitCommit: "818025e9fe34abcdef0123456789abcdef012345", + GitTreeState: "dirty", + BuildDate: "2026-11-03T20:10:02Z", + GoVersion: "go1.27.3", + } + if got != want { + t.Fatalf("got %+v\nwant %+v", got, want) + } + }) + + t.Run("keeps ldflags values", func(t *testing.T) { + in := apimachineryversion.Info{Major: "9", Minor: "8", GitCommit: "abc", GitTreeState: "clean", BuildDate: "2026-01-01T00:00:00Z"} + got := completeInfo(in, "v0.23.3", vcs) + in.GitVersion = "v0.23.3" + if got != in { + t.Fatalf("got %+v\nwant %+v", got, in) + } + }) + + t.Run("clean tree without build info", func(t *testing.T) { + got := completeInfo(defaults, "v0.23.3", nil) + if got.Major != "0" || got.Minor != "23" || got.GitCommit != commitPlaceholder { + t.Fatalf("got %+v", got) + } + }) +}