Skip to content
Merged
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ coverage.out

# Configuration
osctrl-api.json
version_data.json

# Go Workspace
go.work
Expand Down
18 changes: 18 additions & 0 deletions cmd/admin/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,22 @@ func init() {
flags = config.InitAdminFlags(flagParams)
}

// Retrieve latest release information and compare
func checkLatestRelease() {
log.Info().Msg("Checking for the latest release... (timeout 10s)")
latest, err := version.RetrieveVersionData(version.VersionDataURL)
if err != nil {
log.Err(err).Msg("Error retrieving latest release information")
return
}
if version.CheckSuggestedRelease(latest.SuggestedRelease) {
log.Info().Msgf("A newer version of %s is available: %s (current: %s)", serviceName, latest.SuggestedRelease, buildVersion)
log.Info().Msgf("Release notes: %s", latest.MoreInformation)
} else {
log.Info().Msgf("%s is up to date with the latest release (%s)", serviceName, buildVersion)
}
Comment on lines +195 to +200
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

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

The condition is inverted relative to the message: CheckSuggestedRelease returns true when the current version is equal/newer than the suggested release, but the log says “A newer version ... is available”. Either invert the if condition or change CheckSuggestedRelease semantics/name so that true means an update is available.

Copilot uses AI. Check for mistakes.
}

// Go go!
func osctrlAdminService() {
// ////////////////////////////// Backend
Expand Down Expand Up @@ -758,6 +774,8 @@ func main() {
}
// Initialize service logger
initializeLoggers(*flagParams.Service)
// Analyze version and compare with the latest release
checkLatestRelease()
// Service starts!
osctrlAdminService()
return nil
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ require (
go.opentelemetry.io/otel/metric v1.40.0 // indirect
go.opentelemetry.io/otel/trace v1.40.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/mod v0.34.0
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.41.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,8 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
Expand Down
54 changes: 54 additions & 0 deletions pkg/version/version.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,62 @@
package version

import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"

"golang.org/x/mod/semver"
)

const (
// OsctrlVersion to have the version for all components
OsctrlVersion = "0.5.0"
// OsqueryVersion to have the version for osquery defined
OsqueryVersion = "5.21.0"
// VersionDataURL to have the URL to retrieve the latest version for all osctrl components
VersionDataURL = "https://stats.osctrl.net/version_data.json"
// versionDataRequestTimeout sets the max time to wait for version data retrieval.
versionDataRequestTimeout = 10 * time.Second
)
Comment thread
javuto marked this conversation as resolved.

// VersionData to retrieve the latest version for all osctrl components
type VersionData struct {
LatestRelease string `json:"latestRelease"`
OsqueryVersion string `json:"osqueryVersion"`
SuggestedRelease string `json:"suggestedRelease"`
MoreInformation string `json:"moreInformation"`
}

// CheckSuggestedRelease to check if the current version is equal or higher than the suggested release
func CheckSuggestedRelease(suggestedRelease string) bool {
return semver.Compare("v"+OsctrlVersion, "v"+suggestedRelease) >= 0
}
Comment thread
javuto marked this conversation as resolved.

func RetrieveVersionData(url string) (*VersionData, error) {
ctx, cancel := context.WithTimeout(context.Background(), versionDataRequestTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode)
}
data, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var versionData VersionData
if err := json.Unmarshal(data, &versionData); err != nil {
return nil, err
}
return &versionData, nil
}
115 changes: 111 additions & 4 deletions pkg/version/version_test.go
Original file line number Diff line number Diff line change
@@ -1,15 +1,122 @@
package version

import (
"encoding/json"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"testing"

"github.com/stretchr/testify/assert"
)

func TestOsqueryVersion(t *testing.T) {
assert.Equal(t, "5.21.0", OsqueryVersion)
type roundTripFunc func(*http.Request) (*http.Response, error)

func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}

func TestVersionConstantsAreValid(t *testing.T) {
semverPattern := regexp.MustCompile(`^\d+\.\d+\.\d+$`)

assert.Regexp(t, semverPattern, OsctrlVersion)
assert.Regexp(t, semverPattern, OsqueryVersion)
}

func TestVersionDataURL(t *testing.T) {
parsedURL, err := url.ParseRequestURI(VersionDataURL)

assert.NoError(t, err)
assert.Equal(t, "https", parsedURL.Scheme)
assert.Equal(t, "stats.osctrl.net", parsedURL.Host)
assert.Equal(t, "/version_data.json", parsedURL.Path)
}

func TestVersionDataJSONTags(t *testing.T) {
data := VersionData{
OsqueryVersion: "5.21.0",
LatestRelease: "0.5.1",
SuggestedRelease: "0.5.1",
MoreInformation: "https://docs.example.com/releases/0.5.1",
}

encoded, err := json.Marshal(data)
assert.NoError(t, err)

var decoded map[string]string
err = json.Unmarshal(encoded, &decoded)
assert.NoError(t, err)

assert.Equal(t, data.OsqueryVersion, decoded["osqueryVersion"])
assert.Equal(t, data.LatestRelease, decoded["latestRelease"])
assert.Equal(t, data.SuggestedRelease, decoded["suggestedRelease"])
assert.Equal(t, data.MoreInformation, decoded["moreInformation"])
}

func TestOsctrlVersion(t *testing.T) {
assert.Equal(t, "0.5.0", OsctrlVersion)
func TestRetrieveVersionDataSuccess(t *testing.T) {
previousClient := http.DefaultClient
t.Cleanup(func() { http.DefaultClient = previousClient })

http.DefaultClient = &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
assert.Equal(t, http.MethodGet, req.Method)
assert.Equal(t, VersionDataURL, req.URL.String())
assert.NotNil(t, req.Context())

payload := `{"latestRelease":"0.5.1","osqueryVersion":"5.21.0","suggestedRelease":"0.5.1","moreInformation":"https://docs.example.com/releases/0.5.1"}`
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(payload)),
Header: make(http.Header),
}, nil
}),
}

data, err := RetrieveVersionData(VersionDataURL)
assert.NoError(t, err)
assert.NotNil(t, data)
assert.Equal(t, "0.5.1", data.LatestRelease)
assert.Equal(t, "5.21.0", data.OsqueryVersion)
assert.Equal(t, "0.5.1", data.SuggestedRelease)
assert.Equal(t, "https://docs.example.com/releases/0.5.1", data.MoreInformation)
}

func TestRetrieveVersionDataUnexpectedStatus(t *testing.T) {
previousClient := http.DefaultClient
t.Cleanup(func() { http.DefaultClient = previousClient })

http.DefaultClient = &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusBadGateway,
Body: io.NopCloser(strings.NewReader("bad gateway")),
Header: make(http.Header),
}, nil
}),
}

data, err := RetrieveVersionData(VersionDataURL)
assert.Nil(t, data)
assert.EqualError(t, err, "unexpected status code: 502")
}

func TestRetrieveVersionDataInvalidJSON(t *testing.T) {
previousClient := http.DefaultClient
t.Cleanup(func() { http.DefaultClient = previousClient })

http.DefaultClient = &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader("{")),
Header: make(http.Header),
}, nil
}),
}

data, err := RetrieveVersionData(VersionDataURL)
assert.Nil(t, data)
assert.Error(t, err)
}
Loading