diff --git a/CHANGELOG.md b/CHANGELOG.md index 546e6754c..dc3655a3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ * [DNS Upstream Query Modes](#dns-upstream-query-modes) - choose how multiple DNS upstreams are queried: parallel fan-out (default) or serial fail-through * [Controller Read Throughput Under Load](#controller-read-throughput-under-load) - a bbolt upgrade lifts a ceiling on concurrent read transactions that could stall a busy controller * [Logging Now Uses slog with an Async Handler](#logging-now-uses-slog-with-an-async-handler) - Logging moves to Go's `log/slog` behind an asynchronous sink; output is unchanged by default, with new flags to tune buffering +* [Build Flags](#build-flags) - A build of the controller can name the build time choices it was made with, and clients can read them from `/version` * [Security Advisories](#security-advisories) - Eight security advisories, plus the two control-plane certificate validation fixes first released in 2.0.2 ## Security Advisories @@ -496,6 +497,36 @@ most call sites are not yet migrated, per-channel overrides have limited reach today and expand as packages are converted. The global `ziti agent set-log-level ` still affects everything. +## Build Flags + +A controller binary can now carry a list of build flags: short names for the +build time choices it was made with. The `/version` endpoint returns them in a +new `buildFlags` field, and `ziti version -v` prints them, so a client or an +operator can tell what a running controller was built to do without asking the +person who built it. + +Flags are set with the Go linker at build time. There is one symbol, and one +build owner composes the whole list: + +``` +go build -ldflags "-X github.com/openziti/ziti/v2/common/build.buildFlags=ALPHA,BRAVO_MODE" ./ziti +``` + +Names are uppercase letters, digits, and underscores. Anything else in the list +is dropped rather than served. A second `-X` against the same symbol replaces +the first rather than adding to it, and the linker silently ignores an `-X` +whose symbol path it cannot resolve, so a typo in that path yields a binary with +no flags rather than a build error. + +Releases from this repository set no flags, so `buildFlags` is empty and `ziti +version -v` prints no build flags line. The names themselves are defined by +whoever produced the build; a client should ignore any it does not recognize. + +Build flags are not capabilities. `capabilities` on `/version` describes what +the controller offers over the API, is defined by this repository, and is +enumerated at `/enumerated-capabilities`. Build flags describe how the binary +was built, are open-ended, and are enumerated nowhere. + ## Deprecated Features Deprecated features still work, but are no longer recommended and will be removed diff --git a/common/build/flags.go b/common/build/flags.go new file mode 100644 index 000000000..dc91cb596 --- /dev/null +++ b/common/build/flags.go @@ -0,0 +1,63 @@ +package build + +import ( + "regexp" + "strings" +) + +const ( + // maxBuildFlags is the greatest number of flag names accepted from buildFlags. + maxBuildFlags = 32 + + // maxBuildFlagNameLength is the greatest length accepted for a single flag name. + maxBuildFlagNameLength = 64 +) + +// buildFlagNamePattern matches a well formed build flag name. +var buildFlagNamePattern = regexp.MustCompile(`^[A-Z0-9_]+$`) + +// buildFlags is a comma separated list of flag names supplied at build time by the linker, empty +// in a stock build: +// +// -X github.com/openziti/ziti/v2/common/build.buildFlags=ALPHA,BRAVO +// +// The symbol path is a contract with downstream builds. The linker silently ignores -X against a +// symbol it cannot resolve, so renaming this variable or moving it to another package produces a +// binary with no build flags rather than a build error. A second -X against this symbol replaces +// the first rather than adding to it: one build owner composes the whole list. +var buildFlags string + +// GetBuildFlags returns the well formed flag names supplied at build time, in the order they were +// given. It returns an empty slice for a stock build. +func GetBuildFlags() []string { + return parseBuildFlags(buildFlags) +} + +// parseBuildFlags splits raw on commas and returns the well formed names in first seen order, +// dropping blanks, duplicates, and anything outside [A-Z0-9_]. The result is never nil, so it +// serializes as an empty JSON array rather than null. The count of names and the length of each +// are capped, because the result is served over an unauthenticated API. +func parseBuildFlags(raw string) []string { + result := []string{} + seen := map[string]struct{}{} + + for _, token := range strings.Split(raw, ",") { + if len(result) == maxBuildFlags { + break + } + + name := strings.TrimSpace(token) + if len(name) > maxBuildFlagNameLength || !buildFlagNamePattern.MatchString(name) { + continue + } + + if _, ok := seen[name]; ok { + continue + } + + seen[name] = struct{}{} + result = append(result, name) + } + + return result +} diff --git a/common/build/flags_test.go b/common/build/flags_test.go new file mode 100644 index 000000000..0645dc027 --- /dev/null +++ b/common/build/flags_test.go @@ -0,0 +1,87 @@ +package build + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestParseBuildFlags(t *testing.T) { + tests := []struct { + name string + raw string + expected []string + }{ + { + name: "empty string yields an empty, non nil slice", + raw: "", + expected: []string{}, + }, + { + name: "single name", + raw: "ALPHA", + expected: []string{"ALPHA"}, + }, + { + name: "multiple names keep their order", + raw: "ALPHA,BRAVO,CHARLIE", + expected: []string{"ALPHA", "BRAVO", "CHARLIE"}, + }, + { + name: "surrounding whitespace is trimmed", + raw: " ALPHA , BRAVO\t,\nCHARLIE ", + expected: []string{"ALPHA", "BRAVO", "CHARLIE"}, + }, + { + name: "blank tokens are dropped", + raw: ",ALPHA,, ,BRAVO,", + expected: []string{"ALPHA", "BRAVO"}, + }, + { + name: "duplicates are dropped, first occurrence wins", + raw: "ALPHA,BRAVO,ALPHA", + expected: []string{"ALPHA", "BRAVO"}, + }, + { + name: "digits and underscores are accepted", + raw: "ALPHA_2,BRAVO_MODE,3", + expected: []string{"ALPHA_2", "BRAVO_MODE", "3"}, + }, + { + name: "malformed tokens are dropped, well formed ones survive", + raw: "alpha,BRAVO,Char-lie,DELTA ECHO,FOXTROT!,GOLF", + expected: []string{"BRAVO", "GOLF"}, + }, + { + name: "names longer than the cap are dropped", + raw: "ALPHA," + strings.Repeat("B", maxBuildFlagNameLength+1) + ",CHARLIE", + expected: []string{"ALPHA", "CHARLIE"}, + }, + { + name: "names exactly at the length cap are kept", + raw: strings.Repeat("B", maxBuildFlagNameLength), + expected: []string{strings.Repeat("B", maxBuildFlagNameLength)}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result := parseBuildFlags(test.raw) + + require.Equal(t, test.expected, result) + }) + } +} + +func TestParseBuildFlagsStopsAtTheCountCap(t *testing.T) { + names := make([]string, 0, maxBuildFlags+5) + for i := 0; i < maxBuildFlags+5; i++ { + names = append(names, "NAME_"+strings.Repeat("X", i)) + } + + result := parseBuildFlags(strings.Join(names, ",")) + + require.Len(t, result, maxBuildFlags, "accepted names must be capped") + require.Equal(t, names[:maxBuildFlags], result, "the first names up to the cap are the ones kept") +} diff --git a/controller/internal/routes/version_router.go b/controller/internal/routes/version_router.go index 5108e0071..31ea46c52 100644 --- a/controller/internal/routes/version_router.go +++ b/controller/internal/routes/version_router.go @@ -105,6 +105,7 @@ func (ir *VersionRouter) buildVersions(ae *env.AppEnv) *rest_model.Version { Version: buildInfo.Version(), APIVersions: map[string]map[string]rest_model.APIVersion{}, Capabilities: []string{}, + BuildFlags: build.GetBuildFlags(), } for apiBinding, apiVersionToPathMap := range webapis.AllApiBindingVersions { diff --git a/go.mod b/go.mod index a05449abe..871a0ac0e 100644 --- a/go.mod +++ b/go.mod @@ -65,7 +65,7 @@ require ( github.com/natefinch/lumberjack v2.0.0+incompatible github.com/openziti/channel/v5 v5.0.27 github.com/openziti/cobra-to-md v1.0.1 - github.com/openziti/edge-api v0.35.2 + github.com/openziti/edge-api v0.36.0 github.com/openziti/foundation/v2 v2.0.100 github.com/openziti/identity v1.0.140 github.com/openziti/jwks v1.0.6 diff --git a/go.sum b/go.sum index 535889e35..a8d820ee9 100644 --- a/go.sum +++ b/go.sum @@ -534,6 +534,8 @@ github.com/openziti/cobra-to-md v1.0.1 h1:WRinNoIRmwWUSJm+pSNXMjOrtU48oxXDZgeCYQ github.com/openziti/cobra-to-md v1.0.1/go.mod h1:FjCpk/yzHF7/r28oSTNr5P57yN5VolpdAtS/g7KNi2c= github.com/openziti/edge-api v0.35.2 h1:+5+i0BkhzsKZkURBAr6fgVp7BnHITjaHDiRxxdW3DuE= github.com/openziti/edge-api v0.35.2/go.mod h1:m1oAQ6+fnkEO0NOAkDy6WpnXDxPhj2sko4eBUvIMxkE= +github.com/openziti/edge-api v0.36.0 h1:adp0gCDbxee3r4tPBCXm7IyLTcIGhDcWmQ9QJMO+AwY= +github.com/openziti/edge-api v0.36.0/go.mod h1:m1oAQ6+fnkEO0NOAkDy6WpnXDxPhj2sko4eBUvIMxkE= github.com/openziti/foundation/v2 v2.0.100 h1:Q9DzyZYp7/QF0No2CDadcmhWpp1FqOvxwhNFENk6GrU= github.com/openziti/foundation/v2 v2.0.100/go.mod h1:voePbn/hwFypw6Ogr2k5sl/g3ZiHWdHO202uHBNpsAQ= github.com/openziti/go-term-markdown v1.0.1 h1:9uzMpK4tav6OtvRxRt99WwPTzAzCh+Pj9zWU2FBp3Qg= diff --git a/ziti/cmd/common/version.go b/ziti/cmd/common/version.go index ae1b53655..8066de7af 100644 --- a/ziti/cmd/common/version.go +++ b/ziti/cmd/common/version.go @@ -18,7 +18,9 @@ package common import ( "fmt" + "strings" + "github.com/openziti/ziti/v2/common/build" "github.com/openziti/ziti/v2/common/version" "github.com/spf13/cobra" ) @@ -36,6 +38,10 @@ func NewVersionCmd() *cobra.Command { fmt.Printf("Build Date: %s\n", version.GetBuildDate()) fmt.Printf("Go Version: %s\n", version.GetGoVersion()) fmt.Printf("OS/Arch: %s/%s\n", version.GetOS(), version.GetArchitecture()) + + if buildFlags := build.GetBuildFlags(); len(buildFlags) > 0 { + fmt.Printf("Build Flags: %s\n", strings.Join(buildFlags, ", ")) + } } else { fmt.Println(version.GetVersion()) }