-
Notifications
You must be signed in to change notification settings - Fork 2
Check client / agent versions over RPC #353
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -165,6 +165,7 @@ linters: | |
| - Formatter | ||
| - gpio | ||
| - port | ||
| - Interceptor | ||
|
|
||
| lll: | ||
| line-length: 140 | ||
|
|
||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's do not touch how the version is backed into the code in this PR. Please just take const |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -24,6 +24,50 @@ func VersionString() string { | |
| return i.String() | ||
| } | ||
|
|
||
| // Semver returns the best-known semantic version of the dutctl build. Release | ||
| // and CI builds stamp [buildVersion] via ldflags, so that wins. For binaries | ||
| // that were not stamped (plain `go build`) or for dutctl imported as a | ||
| // dependency (e.g. contest), it falls back to the module version recorded in | ||
| // the build info, and finally to the compiled-in [Version] default. | ||
| func Semver() string { | ||
| if buildVersion != "" { | ||
| return buildVersion | ||
| } | ||
|
|
||
| if buildInfo, ok := debug.ReadBuildInfo(); ok { | ||
| if v := moduleVersion(buildInfo); v != "" { | ||
| return v | ||
| } | ||
| } | ||
|
|
||
| return Version | ||
| } | ||
|
|
||
| // moduleVersion returns the dutctl module version from the build info: the main | ||
| // module version when a dutctl binary is running via `go install pkg@version`, | ||
| // or the dutctl entry in Deps when dutctl is imported by another module. It | ||
| // returns "" for local (`go build`) and filesystem-replaced builds, which | ||
| // record no usable version — those fall back to the ldflags [Version]. | ||
| func moduleVersion(buildInfo *debug.BuildInfo) string { | ||
| const modulePath = "github.com/BlindspotSoftware/dutctl" | ||
|
|
||
| if buildInfo.Main.Path == modulePath && isRealVersion(buildInfo.Main.Version) { | ||
| return buildInfo.Main.Version | ||
| } | ||
|
|
||
| for _, dep := range buildInfo.Deps { | ||
| if dep.Path == modulePath && isRealVersion(dep.Version) { | ||
| return dep.Version | ||
| } | ||
| } | ||
|
|
||
| return "" | ||
| } | ||
|
|
||
| func isRealVersion(v string) bool { | ||
| return v != "" && v != "(devel)" | ||
| } | ||
|
|
||
| // info is a wrapper for [debug.BuildInfo]. | ||
| type info struct { | ||
| semver string // Semantic release version of the application. | ||
|
|
@@ -51,7 +95,7 @@ func (i *info) String() string { | |
| func (i *info) read() { | ||
| const unknown = "unknown" | ||
|
|
||
| i.semver = Version | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Keep this as is. Concentrate on how to transport and compare the version in this PR. Take this value as the given version info. |
||
| i.semver = Semver() | ||
|
|
||
| if bi, ok := debug.ReadBuildInfo(); ok { | ||
| i.revision = cvsShortHash(findSetting("vcs.revision", bi.Settings)) | ||
|
|
||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Let's do not touch how the version is backed into the code in this PR. Please just take const |
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd move this code to |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,139 @@ | ||
| // Copyright 2025 Blindspot Software | ||
| // Use of this source code is governed by a BSD-style | ||
| // license that can be found in the LICENSE file. | ||
|
|
||
| package versioncheck | ||
|
|
||
| import ( | ||
| "context" | ||
| "errors" | ||
| "fmt" | ||
| "net/http" | ||
| "sync" | ||
|
|
||
| "connectrpc.com/connect" | ||
| ) | ||
|
|
||
| // NewServerInterceptor returns a connect interceptor that stamps the agent's | ||
| // version onto the response header of every RPC (unary and streaming), so | ||
| // clients always learn which agent build served them. | ||
| func NewServerInterceptor(version string) connect.Interceptor { | ||
| return &serverInterceptor{version: version} | ||
| } | ||
|
Comment on lines
+20
to
+22
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is the constructor needed, or can we use defaults when struct fields are unset? Either way, empty strings need to be handled. Where is the advantage of a constructor? |
||
|
|
||
| type serverInterceptor struct { | ||
| version string | ||
| } | ||
|
|
||
| func (i *serverInterceptor) WrapUnary(next connect.UnaryFunc) connect.UnaryFunc { | ||
| return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { | ||
| res, err := next(ctx, req) | ||
| if res != nil { | ||
| res.Header().Set(HeaderName, i.version) | ||
| } | ||
|
|
||
| return res, err | ||
| } | ||
| } | ||
|
|
||
| func (i *serverInterceptor) WrapStreamingClient(next connect.StreamingClientFunc) connect.StreamingClientFunc { | ||
| return next | ||
| } | ||
|
|
||
| func (i *serverInterceptor) WrapStreamingHandler(next connect.StreamingHandlerFunc) connect.StreamingHandlerFunc { | ||
| return func(ctx context.Context, conn connect.StreamingHandlerConn) error { | ||
| conn.ResponseHeader().Set(HeaderName, i.version) | ||
|
|
||
| return next(ctx, conn) | ||
| } | ||
| } | ||
|
|
||
| // NewClientInterceptor returns a connect interceptor that compares the agent | ||
| // version advertised on each response (via HeaderName) against self. On a Warn | ||
| // it calls onWarn once per connection; on an Error it fails the call with a | ||
| // FailedPrecondition error. onWarn may be nil. | ||
| func NewClientInterceptor(self string, onWarn, onInfo func(string)) connect.Interceptor { | ||
| return &clientInterceptor{self: self, onWarn: onWarn, onInfo: onInfo} | ||
| } | ||
|
|
||
| type clientInterceptor struct { | ||
| self string | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Be consistent with the names of the version strings ("version" vs. "self") |
||
| onWarn func(string) | ||
| onInfo func(string) | ||
|
|
||
| once sync.Once | ||
| result error | ||
| } | ||
|
|
||
| // check classifies the peer version carried in header. It reports the observed | ||
| // version (onInfo) and any warning (onWarn) at most once per client, and returns | ||
| // — on every call — the cached error when the pairing is incompatible. | ||
| func (i *clientInterceptor) check(header http.Header) error { | ||
| i.once.Do(func() { | ||
| peer := header.Get(HeaderName) | ||
|
|
||
| if i.onInfo != nil { | ||
| i.onInfo(fmt.Sprintf("dutagent version: %s", peer)) | ||
| } | ||
|
|
||
| act, reason := classify(i.self, peer) | ||
| switch act { | ||
| case actionError: | ||
| i.result = connect.NewError(connect.CodeFailedPrecondition, errors.New(reason)) | ||
| case actionWarn: | ||
| if i.onWarn != nil && reason != "" { | ||
| i.onWarn(reason) | ||
| } | ||
| case actionOK: | ||
| } | ||
| }) | ||
|
|
||
| return i.result | ||
| } | ||
|
|
||
| func (i *clientInterceptor) WrapUnary(next connect.UnaryFunc) connect.UnaryFunc { | ||
| return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) { | ||
| res, err := next(ctx, req) | ||
| if err != nil { | ||
| return res, err | ||
| } | ||
|
|
||
| cerr := i.check(res.Header()) | ||
| if cerr != nil { | ||
| return nil, cerr | ||
| } | ||
|
|
||
| return res, nil | ||
| } | ||
| } | ||
|
|
||
| func (i *clientInterceptor) WrapStreamingHandler(next connect.StreamingHandlerFunc) connect.StreamingHandlerFunc { | ||
| return next | ||
| } | ||
|
|
||
| func (i *clientInterceptor) WrapStreamingClient(next connect.StreamingClientFunc) connect.StreamingClientFunc { | ||
| return func(ctx context.Context, spec connect.Spec) connect.StreamingClientConn { | ||
| return &checkingClientConn{ | ||
| StreamingClientConn: next(ctx, spec), | ||
| interceptor: i, | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // checkingClientConn runs the version check after the first response is received | ||
| // (which is when the response header becomes available). The interceptor's own | ||
| // once guard makes the repeated calls cheap. | ||
| type checkingClientConn struct { | ||
| connect.StreamingClientConn | ||
|
|
||
| interceptor *clientInterceptor | ||
| } | ||
|
|
||
| func (c *checkingClientConn) Receive(msg any) error { | ||
| err := c.StreamingClientConn.Receive(msg) | ||
| if err != nil { | ||
| return err | ||
| } | ||
|
|
||
| return c.interceptor.check(c.ResponseHeader()) | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's do not touch how the version is backed into the code in this PR. Please just take const
internal/buildinfo.Versionas given.