diff --git a/Makefile b/Makefile index 0e2855e..667c08b 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,14 @@ -BINARY := rossoctl -PKG := github.com/rossoctl/rossoctl-cli -CMD_PKG := $(PKG)/cmd +BINARY := rossoctl +PKG := github.com/rossoctl/rossoctl-cli +CMD_PKG := $(PKG)/cmd +BUILDINFO_PKG := $(PKG)/internal/buildinfo VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo none) DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ) LDFLAGS := -s -w \ - -X '$(CMD_PKG).version=$(VERSION)' \ + -X '$(BUILDINFO_PKG).Version=$(VERSION)' \ -X '$(CMD_PKG).commit=$(COMMIT)' \ -X '$(CMD_PKG).date=$(DATE)' diff --git a/cmd/root.go b/cmd/root.go index 7bf85d3..8e1f7ae 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -22,11 +22,12 @@ import ( "github.com/rossoctl/rossoctl-cli/internal/rossoctlclient" ) -// These are set at build time via -ldflags. See the Makefile. +// commit and date are set at build time via -ldflags. See the Makefile. +// The version itself lives in buildinfo.Version, so non-Cobra packages can +// report it too. var ( - version = "dev" - commit = "none" - date = "unknown" + commit = "none" + date = "unknown" ) // defaultServer is the API endpoint used when --server is not supplied. diff --git a/cmd/version.go b/cmd/version.go index b2ca528..547054c 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -1,6 +1,8 @@ package cmd import ( + "strings" + "github.com/spf13/cobra" "github.com/rossoctl/rossoctl-cli/internal/buildinfo" @@ -11,12 +13,29 @@ var versionCmd = &cobra.Command{ Short: "Print the version information", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - info := buildinfo.Info{Version: version, Commit: commit, Date: date} + info := buildinfo.Info{Version: buildinfo.Version, Commit: commit, Date: date} cmd.Println(info.String()) + cmd.Println("server version: " + serverVersion(cmd)) return nil }, } +// serverVersion reports the connected server's version, as returned by +// GET /auth/config. It returns "unknown" whenever the server is unreachable +// or doesn't report a version, so `version` never fails just because the +// server is down. +func serverVersion(cmd *cobra.Command) string { + client, err := newClient(cmd) + if err != nil { + return "unknown" + } + cfg, err := client.GetAuthConfig(cmd.Context()) + if err != nil || cfg.Version == nil || strings.TrimSpace(*cfg.Version) == "" { + return "unknown" + } + return *cfg.Version +} + func init() { rootCmd.AddCommand(versionCmd) } diff --git a/cmd/version_test.go b/cmd/version_test.go new file mode 100644 index 0000000..c7bcbdc --- /dev/null +++ b/cmd/version_test.go @@ -0,0 +1,55 @@ +package cmd + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func newVersionAuthConfigServer(t *testing.T, body string) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/auth/config" { + t.Errorf("unexpected path %q", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(body)) + })) + t.Cleanup(srv.Close) + return srv +} + +func TestVersionReportsServerVersion(t *testing.T) { + srv := newVersionAuthConfigServer(t, `{"enabled": true, "version": "v0.8.0-alpha.1"}`) + + out, err := execute(t, "--server", srv.URL+"/api/v1/", "version") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(out, "server version: v0.8.0-alpha.1") { + t.Errorf("output = %q, want it to contain server version", out) + } +} + +func TestVersionUnknownWhenServerOmitsVersion(t *testing.T) { + srv := newVersionAuthConfigServer(t, `{"enabled": false}`) + + out, err := execute(t, "--server", srv.URL+"/api/v1/", "version") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(out, "server version: unknown") { + t.Errorf("output = %q, want it to report unknown server version", out) + } +} + +func TestVersionUnknownWhenServerUnreachable(t *testing.T) { + out, err := execute(t, "--server", "http://127.0.0.1:1/api/v1/", "version") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(out, "server version: unknown") { + t.Errorf("output = %q, want it to report unknown server version", out) + } +} diff --git a/internal/apiclient/apiclient.go b/internal/apiclient/apiclient.go index b941615..e1335a7 100644 --- a/internal/apiclient/apiclient.go +++ b/internal/apiclient/apiclient.go @@ -100,6 +100,7 @@ func (e *StatusError) Error() string { // is distinguishable from "empty string" when rendering. type AuthConfig struct { Enabled bool `json:"enabled"` + Version *string `json:"version"` KeycloakURL *string `json:"keycloak_url"` Realm *string `json:"realm"` ClientID *string `json:"client_id"` diff --git a/internal/buildinfo/buildinfo.go b/internal/buildinfo/buildinfo.go index b0e7aab..faba3c7 100644 --- a/internal/buildinfo/buildinfo.go +++ b/internal/buildinfo/buildinfo.go @@ -8,6 +8,11 @@ package buildinfo import "fmt" +// Version is the CLI's build version, set at build time via -ldflags (see the +// Makefile). It lives here, rather than in cmd, so that non-Cobra packages +// such as internal/serve can report it without importing cmd. +var Version = "dev" + // Info describes how the binary was built. type Info struct { Version string diff --git a/internal/serve/serve.go b/internal/serve/serve.go index 4780434..9c92c23 100644 --- a/internal/serve/serve.go +++ b/internal/serve/serve.go @@ -55,6 +55,7 @@ import ( "unicode/utf8" "github.com/rossoctl/rossoctl-cli/internal/agentapi" + "github.com/rossoctl/rossoctl-cli/internal/buildinfo" "github.com/rossoctl/rossoctl-cli/internal/instances" ) @@ -210,6 +211,7 @@ func HealthRoutes() []Route { return append([]Route(nil), healthRoutes...) } // fields are nullable and omitted while auth is disabled. type AuthConfig struct { Enabled bool `json:"enabled"` + Version *string `json:"version,omitempty"` KeycloakURL *string `json:"keycloak_url,omitempty"` Realm *string `json:"realm,omitempty"` ClientID *string `json:"client_id,omitempty"` @@ -488,7 +490,8 @@ func readyRoute(opts) http.HandlerFunc { // answering "disabled" lets it proceed straight to the API. func authConfigRoute(opts) http.HandlerFunc { return func(w http.ResponseWriter, _ *http.Request) { - writeJSON(w, http.StatusOK, AuthConfig{Enabled: false}) + version := buildinfo.Version + writeJSON(w, http.StatusOK, AuthConfig{Enabled: false, Version: &version}) } } diff --git a/internal/serve/serve_test.go b/internal/serve/serve_test.go index aa8ed19..ee48c80 100644 --- a/internal/serve/serve_test.go +++ b/internal/serve/serve_test.go @@ -10,6 +10,8 @@ import ( "slices" "strings" "testing" + + "github.com/rossoctl/rossoctl-cli/internal/buildinfo" ) // testNamespaces are the namespaces newTestServer serves, distinct from the @@ -60,13 +62,16 @@ func TestAuthConfigReportsDisabled(t *testing.T) { if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { t.Fatalf("decode: %v", err) } - want := map[string]any{"enabled": false} + want := map[string]any{"enabled": false, "version": buildinfo.Version} if len(got) != len(want) { t.Fatalf("body = %v, want exactly %v", got, want) } if got["enabled"] != false { t.Errorf("enabled = %v, want false", got["enabled"]) } + if got["version"] != buildinfo.Version { + t.Errorf("version = %v, want %v", got["version"], buildinfo.Version) + } } // implementedRoutes are the operations with a real implementation, excluded