Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions .github/workflows/release.yml

Copy link
Copy Markdown
Member

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.Version as given.

Original file line number Diff line number Diff line change
Expand Up @@ -35,23 +35,23 @@ jobs:

- name: Build dutctl for amd64
if: ${{ steps.release.outputs.release_created }}
run: GOOS=linux GOARCH=amd64 go build -o dutctl-${{ steps.release.outputs.tag_name }}-linux-amd64 ./cmds/dutctl
run: GOOS=linux GOARCH=amd64 go build -ldflags "-X github.com/BlindspotSoftware/dutctl/internal/buildinfo.buildVersion=${{ steps.release.outputs.tag_name }}" -o dutctl-${{ steps.release.outputs.tag_name }}-linux-amd64 ./cmds/dutctl

- name: Build dutagent for amd64
if: ${{ steps.release.outputs.release_created }}
run: GOOS=linux GOARCH=amd64 go build -o dutagent-${{ steps.release.outputs.tag_name }}-linux-amd64 ./cmds/dutagent
run: GOOS=linux GOARCH=amd64 go build -ldflags "-X github.com/BlindspotSoftware/dutctl/internal/buildinfo.buildVersion=${{ steps.release.outputs.tag_name }}" -o dutagent-${{ steps.release.outputs.tag_name }}-linux-amd64 ./cmds/dutagent

- name: Build dutagent for arm64
if: ${{ steps.release.outputs.release_created }}
run: GOOS=linux GOARCH=arm64 go build -o dutagent-${{ steps.release.outputs.tag_name }}-linux-arm64 ./cmds/dutagent
run: GOOS=linux GOARCH=arm64 go build -ldflags "-X github.com/BlindspotSoftware/dutctl/internal/buildinfo.buildVersion=${{ steps.release.outputs.tag_name }}" -o dutagent-${{ steps.release.outputs.tag_name }}-linux-arm64 ./cmds/dutagent

- name: Build dutserver for amd64
if: ${{ steps.release.outputs.release_created }}
run: GOOS=linux GOARCH=amd64 go build -o dutserver-${{ steps.release.outputs.tag_name }}-linux-amd64 ./cmds/exp/dutserver
run: GOOS=linux GOARCH=amd64 go build -ldflags "-X github.com/BlindspotSoftware/dutctl/internal/buildinfo.buildVersion=${{ steps.release.outputs.tag_name }}" -o dutserver-${{ steps.release.outputs.tag_name }}-linux-amd64 ./cmds/exp/dutserver

- name: Build dutserver for arm64
if: ${{ steps.release.outputs.release_created }}
run: GOOS=linux GOARCH=arm64 go build -o dutserver-${{ steps.release.outputs.tag_name }}-linux-arm64 ./cmds/exp/dutserver
run: GOOS=linux GOARCH=arm64 go build -ldflags "-X github.com/BlindspotSoftware/dutctl/internal/buildinfo.buildVersion=${{ steps.release.outputs.tag_name }}" -o dutserver-${{ steps.release.outputs.tag_name }}-linux-arm64 ./cmds/exp/dutserver


- name: Upload dutctl-linux-amd64
Expand Down
1 change: 1 addition & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ linters:
- Formatter
- gpio
- port
- Interceptor

lll:
line-length: 140
Expand Down
6 changes: 3 additions & 3 deletions .goreleaser.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ builds:
main: ./cmds/dutctl
binary: "dutctl"
ldflags:
- -s -w -X main.builtBy=goreleaser
- -s -w -X main.builtBy=goreleaser -X github.com/BlindspotSoftware/dutctl/internal/buildinfo.buildVersion={{ .Version }}
env:
- CGO_ENABLED=0
goos:
Expand All @@ -35,7 +35,7 @@ builds:
main: ./cmds/dutagent
binary: "dutagent"
ldflags:
- -s -w -X main.builtBy=goreleaser
- -s -w -X main.builtBy=goreleaser -X github.com/BlindspotSoftware/dutctl/internal/buildinfo.buildVersion={{ .Version }}
env:
- CGO_ENABLED=0
goos:
Expand All @@ -56,7 +56,7 @@ builds:
main: ./cmds/exp/dutserver
binary: "dutserver"
ldflags:
- -s -w -X main.builtBy=goreleaser
- -s -w -X main.builtBy=goreleaser -X github.com/BlindspotSoftware/dutctl/internal/buildinfo.buildVersion={{ .Version }}
env:
- CGO_ENABLED=0
goos:
Expand Down
6 changes: 5 additions & 1 deletion cmds/dutagent/dutagent.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"github.com/BlindspotSoftware/dutctl/internal/buildinfo"
"github.com/BlindspotSoftware/dutctl/internal/dutagent"
"github.com/BlindspotSoftware/dutctl/pkg/dut"
"github.com/BlindspotSoftware/dutctl/pkg/versioncheck"
"github.com/BlindspotSoftware/dutctl/protobuf/gen/dutctl/v1/dutctlv1connect"
"gopkg.in/yaml.v3"

Expand Down Expand Up @@ -164,7 +165,10 @@ func (agt *agent) startRPCService() error {
}

mux := http.NewServeMux()
path, handler := dutctlv1connect.NewDeviceServiceHandler(service)
path, handler := dutctlv1connect.NewDeviceServiceHandler(
service,
connect.WithInterceptors(versioncheck.NewServerInterceptor(versioncheck.SelfVersion())),
)
mux.Handle(path, handler)

// Serve HTTP/2 without TLS (h2c)
Expand Down
6 changes: 6 additions & 0 deletions cmds/dutctl/dutctl.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"github.com/BlindspotSoftware/dutctl/internal/buildinfo"
"github.com/BlindspotSoftware/dutctl/internal/output"
"github.com/BlindspotSoftware/dutctl/pkg/lock"
"github.com/BlindspotSoftware/dutctl/pkg/versioncheck"
"github.com/BlindspotSoftware/dutctl/protobuf/gen/dutctl/v1/dutctlv1connect"
)

Expand Down Expand Up @@ -152,6 +153,11 @@ func (app *application) setupRPCClient() {
newInsecureClient(),
fmt.Sprintf("http://%s", app.serverAddr),
connect.WithGRPC(),
connect.WithInterceptors(versioncheck.NewClientInterceptor(
versioncheck.SelfVersion(),
func(msg string) { slog.Warn(msg) },
nil,
)),
)

app.rpcClient = client
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ require (
github.com/stianeikeland/go-rpio/v4 v4.6.0
go.bug.st/serial v1.7.1
golang.org/x/crypto v0.53.0
golang.org/x/mod v0.36.0
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
)
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ go.bug.st/serial v1.7.1 h1:5aP8wYL0UjEYOVs3oPAGscjaSfRQLHtCvBFXNN/rwtc=
go.bug.st/serial v1.7.1/go.mod h1:d0MmS16Qt9b1m06yoYRNUXhRRTJV5Qg2S5EKqQtnayQ=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
Expand Down
46 changes: 45 additions & 1 deletion internal/buildinfo/buildinfo.go

Copy link
Copy Markdown
Member

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.Version as given.

Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -51,7 +95,7 @@ func (i *info) String() string {
func (i *info) read() {
const unknown = "unknown"

i.semver = Version

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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))
Expand Down
12 changes: 10 additions & 2 deletions internal/buildinfo/version.go

Copy link
Copy Markdown
Member

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.Version as given.

Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@

package buildinfo

// Version is the released semantic version of the application.
// It is bumped automatically by release-please on every release.
// Version is the released semantic version of the application, bumped
// automatically by release-please. It is the fallback used when no build-time
// version was injected (and when dutctl is imported as a dependency).
const Version = "1.0.0-alpha.1" // x-release-please-version

// buildVersion is the version stamped at build time via
// -ldflags "-X .../buildinfo.buildVersion=<git describe>" by release and CI
// builds. It is deliberately empty by default so [Semver] can tell an injected
// build apart from a plain `go build` (whose VCS-derived module version would
// otherwise shadow the ldflags value).
var buildVersion string //nolint:gochecknoglobals // set via -ldflags at build time
139 changes: 139 additions & 0 deletions pkg/versioncheck/interceptor.go

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd move this code to internal/rpc or even also to internal/buildinfo together with adding the intent of the interceptor type to its name -> used as buildinfo.ClientVersionRPCInterceptor

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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())
}
Loading