diff --git a/.github/workflows/publish-custom.yml b/.github/workflows/publish-custom.yml index cb5370bc9..86b9e7545 100644 --- a/.github/workflows/publish-custom.yml +++ b/.github/workflows/publish-custom.yml @@ -5,6 +5,7 @@ on: inputs: tag: description: Custom tag to publish + default: custom platforms: description: Platforms to publish to (comma separated list) default: linux/amd64 @@ -39,6 +40,8 @@ jobs: images: ${{ env.GHCR_IMAGE_REPOSITORY }} tags: | type=raw,value=${{ inputs.tag }} + - name: Get commit timestamp + run: echo "COMMIT_TIMESTAMP=$(TZ=UTC git show -s --format=%cd --date=iso-strict-local)" >> $GITHUB_ENV - name: Build and push Docker image uses: docker/build-push-action@v6 with: @@ -47,3 +50,7 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + build-args: | + VERSION=${{ inputs.tag }} + REVISION=${{ github.sha }} + REVISION_DATE=${{ env.COMMIT_TIMESTAMP }} diff --git a/.github/workflows/publish-experimental.yml b/.github/workflows/publish-experimental.yml index 229a98d30..d07db8717 100644 --- a/.github/workflows/publish-experimental.yml +++ b/.github/workflows/publish-experimental.yml @@ -24,6 +24,8 @@ jobs: images: ${{ env.IMAGE_REPOSITORY }} tags: | type=raw,value=experimental + - name: Get commit timestamp + run: echo "COMMIT_TIMESTAMP=$(TZ=UTC git show -s --format=%cd --date=iso-strict-local)" >> $GITHUB_ENV - name: Build and push Docker image uses: docker/build-push-action@v6 with: @@ -32,3 +34,7 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + build-args: | + VERSION=experimental + REVISION=${{ github.sha }} + REVISION_DATE=${{ env.COMMIT_TIMESTAMP }} diff --git a/.github/workflows/publish-latest.yml b/.github/workflows/publish-latest.yml index bdabb42f2..336d7a981 100644 --- a/.github/workflows/publish-latest.yml +++ b/.github/workflows/publish-latest.yml @@ -42,6 +42,8 @@ jobs: ${{ env.GHCR_IMAGE_REPOSITORY }} tags: | type=raw,value=latest + - name: Get commit timestamp + run: echo "COMMIT_TIMESTAMP=$(TZ=UTC git show -s --format=%cd --date=iso-strict-local)" >> $GITHUB_ENV - name: Build and push Docker image uses: docker/build-push-action@v6 with: @@ -50,3 +52,7 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + build-args: | + VERSION=latest + REVISION=${{ github.sha }} + REVISION_DATE=${{ env.COMMIT_TIMESTAMP }} diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index 7a167523c..6d4d95366 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -41,6 +41,8 @@ jobs: type=raw,value=${{ env.RELEASE }} type=raw,value=stable type=raw,value=latest + - name: Get commit timestamp + run: echo "COMMIT_TIMESTAMP=$(TZ=UTC git show -s --format=%cd --date=iso-strict-local)" >> $GITHUB_ENV - name: Build and push Docker image uses: docker/build-push-action@v6 with: @@ -49,3 +51,7 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + build-args: | + VERSION=${{ env.RELEASE }} + REVISION=${{ github.sha }} + REVISION_DATE=${{ env.COMMIT_TIMESTAMP }} diff --git a/Dockerfile b/Dockerfile index 76b80012b..ef97b3365 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,19 @@ # Build the go application into a binary FROM golang:alpine AS builder + +ARG VERSION +ARG REVISION +ARG REVISION_DATE + RUN apk --update add ca-certificates WORKDIR /app COPY . ./ RUN go mod tidy -diff -RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o gatus . +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo \ +-ldflags "-X github.com/TwiN/gatus/v5/buildinfo.version=${VERSION} \ +-X github.com/TwiN/gatus/v5/buildinfo.revision=${REVISION} \ +-X github.com/TwiN/gatus/v5/buildinfo.revisionDate=${REVISION_DATE}" \ +-o gatus . # Run Tests inside docker image if you don't have a configured go environment #RUN apk update && apk add --virtual build-dependencies build-base gcc diff --git a/Makefile b/Makefile index 62f1dad3b..cfb03fb58 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,9 @@ BINARY=gatus +VERSION=$(shell git describe --tags --exact-match 2> /dev/null) .PHONY: install install: - go build -v -o $(BINARY) . + go build -v -ldflags "-X github.com/TwiN/gatus/v5/buildinfo.version=$(VERSION)" -o $(BINARY) . .PHONY: run run: @@ -25,8 +26,12 @@ test: # Docker # ########## +DIRTY=$(shell test -n "$$(git status --porcelain)" && echo "-dirty") docker-build: - docker build -t twinproduction/gatus:latest . + docker build --build-arg VERSION=$(VERSION) \ + --build-arg REVISION=$(shell git rev-parse HEAD)$(DIRTY) \ + --build-arg REVISION_DATE=$(shell TZ=UTC git show -s --format=%cd --date=iso-strict-local) \ + -t twinproduction/gatus:latest . docker-run: docker run -p 8080:8080 --name gatus twinproduction/gatus:latest diff --git a/README.md b/README.md index 233528c62..ad07ecd12 100644 --- a/README.md +++ b/README.md @@ -540,6 +540,7 @@ Allows you to configure the application wide defaults for the dashboard's UI. So | `ui.dark-mode` | Whether to enable dark mode by default. Note that this is superseded by the user's operating system theme preferences. | `true` | | `ui.default-sort-by` | Default sorting option for endpoints in the dashboard. Can be `name`, `group`, or `health`. Note that user preferences override this. | `name` | | `ui.default-filter-by` | Default filter option for endpoints in the dashboard. Can be `none`, `failing`, or `unstable`. Note that user preferences override this. | `none` | +| `ui.show-version` | Show version in footer | `false` | ### Announcements System-wide announcements allow you to display important messages at the top of the status page. These can be used to inform users about planned maintenance, ongoing issues, or general information. You can use markdown to format your announcements. @@ -2700,6 +2701,7 @@ endpoint on the same port your application is configured to run on (`web.port`). | Metric name | Type | Description | Labels | Relevant endpoint types | |:---------------------------------------------|:--------|:---------------------------------------------------------------------------|:--------------------------------|:------------------------| +| gatus_build_info | gauge | Build information about the instance | version, revision, build_date | None | | gatus_results_total | counter | Number of results per endpoint per success state | key, group, name, type, success | All | | gatus_results_code_total | counter | Total number of results by code | key, group, name, type, code | DNS, HTTP | | gatus_results_connected_total | counter | Total number of results in which a connection was successfully established | key, group, name, type | All | diff --git a/buildinfo/buildinfo.go b/buildinfo/buildinfo.go new file mode 100644 index 000000000..32f0a406e --- /dev/null +++ b/buildinfo/buildinfo.go @@ -0,0 +1,84 @@ +package buildinfo + +import ( + "runtime" + "runtime/debug" +) + +const ( + defaultVersion = "development" + defaultRevision = "unknown" + defaultRevisionDate = "unknown" +) + +var ( + version string + revision string + revisionDate string + + buildInfo = SetBuildInfo(GetDefault()) +) + +type BuildInfo struct { + Version string + Revision string + RevisionDate string + GoVersion string + Dirty bool +} + +func SetEmbedded(info *BuildInfo) { + rawInfo, ok := debug.ReadBuildInfo() + if !ok { + info.Revision += "-buildinfo-err" + } + for _, s := range rawInfo.Settings { + switch s.Key { + case "vcs.revision": + info.Revision = s.Value + case "vcs.time": + info.RevisionDate = s.Value + case "vcs.modified": + info.Dirty = s.Value == "true" + } + } +} + +func SetLdflags(info *BuildInfo) { + if len(version) > 0 { + info.Version = version + } + if len(revision) > 0 { + info.Revision = revision + } + if len(revisionDate) > 0 { + info.RevisionDate = revisionDate + } + if len(info.GoVersion) == 0 { + info.GoVersion = runtime.Version() + } +} + +func GetDefault() BuildInfo { + return BuildInfo{ + Version: defaultVersion, + Revision: defaultRevision, + RevisionDate: defaultRevisionDate, + GoVersion: runtime.Version(), + Dirty: false, + } +} + +func SetBuildInfo(info BuildInfo) BuildInfo { + SetEmbedded(&info) + SetLdflags(&info) + + if info.Dirty { + info.Revision += "-dirty" + } + return info +} + +func Get() BuildInfo { + return buildInfo +} diff --git a/buildinfo/buildinfo_test.go b/buildinfo/buildinfo_test.go new file mode 100644 index 000000000..13ed2efce --- /dev/null +++ b/buildinfo/buildinfo_test.go @@ -0,0 +1,167 @@ +package buildinfo + +import ( + "runtime" + "testing" +) + +func TestBuildInfo_GetDefaults(t *testing.T) { + info := GetDefault() + if info.Version != defaultVersion { + t.Errorf("Expected default Version '%s', got '%s'", defaultVersion, info.Version) + } + if info.Revision != defaultRevision { + t.Errorf("Expected default Revision '%s', got '%s'", defaultRevision, info.Revision) + } + if info.RevisionDate != defaultRevisionDate { + t.Errorf("Expected default RevisionDate '%s', got '%s'", defaultRevisionDate, info.RevisionDate) + } + if info.GoVersion != runtime.Version() { + t.Errorf("Expected GoVersion '%s', got '%s'", runtime.Version(), info.GoVersion) + } + if info.Dirty != false { + t.Errorf("Expected Dirty 'false', got '%v'", info.Dirty) + } +} + +func TestBuildInfo_SetLdflags(t *testing.T) { + t.Run("NoLdflags", func(t *testing.T) { + info := GetDefault() + SetLdflags(&info) + + // Since we are not setting ldflags during the test, the values largely remain defaults + if info.Version != defaultVersion { + t.Errorf("Expected Version '%s', got '%s'", defaultVersion, info.Version) + } + if info.Revision != defaultRevision { + t.Errorf("Expected Revision '%s', got '%s'", defaultRevision, info.Revision) + } + if info.RevisionDate != defaultRevisionDate { + t.Errorf("Expected RevisionDate '%s', got '%s'", defaultRevisionDate, info.RevisionDate) + } + if info.GoVersion != runtime.Version() { + t.Errorf("Expected GoVersion '%s', got '%s'", runtime.Version(), info.GoVersion) + } + if info.Dirty != false { + t.Errorf("Expected Dirty 'false', got '%v'", info.Dirty) + } + }) + t.Run("WithLdflags", func(t *testing.T) { + // Simulate ldflags being set + originalVersion := version + originalRevision := revision + originalRevisionDate := revisionDate + defer func() { + version = originalVersion + revision = originalRevision + revisionDate = originalRevisionDate + }() + version = "test-version" + revision = "test-revision" + revisionDate = "test-date" + + info := GetDefault() + SetLdflags(&info) + + if info.Version != "test-version" { + t.Errorf("Expected Version 'test-version', got '%s'", info.Version) + } + if info.Revision != "test-revision" { + t.Errorf("Expected Revision 'test-revision', got '%s'", info.Revision) + } + if info.RevisionDate != "test-date" { + t.Errorf("Expected RevisionDate ''test-date', got '%s'", info.RevisionDate) + } + if info.GoVersion != runtime.Version() { + t.Errorf("Expected GoVersion '%s', got '%s'", runtime.Version(), info.GoVersion) + } + if info.Dirty != false { + t.Errorf("Expected Dirty 'false', got '%v'", info.Dirty) + } + }) +} + +func TestBuildInfo_SetEmbedded(t *testing.T) { + info := GetDefault() + SetEmbedded(&info) + + if info.Version != defaultVersion { + t.Errorf("Expected Version '%s', got '%s'", defaultVersion, info.Version) + } + if info.Revision != defaultRevision { + t.Errorf("Expected Revision '%s', got '%s'", defaultRevision, info.Revision) + } + if info.RevisionDate != defaultRevisionDate { + t.Errorf("Expected RevisionDate '%s', got '%s'", defaultRevisionDate, info.RevisionDate) + } + if info.GoVersion != runtime.Version() { + t.Errorf("Expected GoVersion '%s', got '%s'", runtime.Version(), info.GoVersion) + } + if info.Dirty != false { + t.Errorf("Expected Dirty 'false', got '%v'", info.Dirty) + } +} + +func TestBuildInfo_SetBuildInfo(t *testing.T) { + t.Run("TestEmbeddOverwriteLdflags", func(t *testing.T) { + // Only change one ldflag at a time to test their effects + originalVersion := version + defer func() { + version = originalVersion + }() + version = "test-version" + + defaultInfo := GetDefault() + info := SetBuildInfo(defaultInfo) + if info.Version != "test-version" { + t.Errorf("Expected Version 'test-version', got '%s'", info.Version) + } + if info.Revision != defaultRevision { + t.Errorf("Expected Revision '%s', got '%s'", defaultRevision, info.Revision) + } + if info.RevisionDate != defaultRevisionDate { + t.Errorf("Expected RevisionDate '%s', got '%s'", defaultRevisionDate, info.RevisionDate) + } + if info.GoVersion != runtime.Version() { + t.Errorf("Expected GoVersion '%s', got '%s'", runtime.Version(), info.GoVersion) + } + if info.Dirty != false { + t.Errorf("Expected Dirty 'false', got '%v'", info.Dirty) + } + + // Change another ldflag + originalRevision := revision + defer func() { + revision = originalRevision + }() + revision = "test-rev" + + info = SetBuildInfo(GetDefault()) + if info.Revision != "test-rev" { + t.Errorf("Expected Revision 'test-rev', got '%s'", info.Revision) + } + + // Change the last ldflag + originalRevisionDate := revisionDate + defer func() { + revisionDate = originalRevisionDate + }() + revisionDate = "test-date" + + info = SetBuildInfo(defaultInfo) + if info.RevisionDate != "test-date" { + t.Errorf("Expected RevisionDate 'test-date', got '%s'", info.RevisionDate) + } + }) + t.Run("TestDirtyFlag", func(t *testing.T) { + info := GetDefault() + info.Dirty = true // Simulate the embedded info setting Dirty to true + info = SetBuildInfo(info) + if info.Dirty != true { + t.Errorf("Expected Dirty 'true', got '%v'", info.Dirty) + } + if info.Revision != defaultRevision+"-dirty" { + t.Errorf("Expected Revision '%s-dirty', got '%s'", defaultRevision, info.Revision) + } + }) +} diff --git a/config.yaml b/config.yaml index f386ea273..f12eafcf7 100644 --- a/config.yaml +++ b/config.yaml @@ -1,3 +1,6 @@ +ui: + show-version: true + endpoints: - name: front-end group: core diff --git a/config/ui/ui.go b/config/ui/ui.go index e56a8c5c4..a269a604d 100644 --- a/config/ui/ui.go +++ b/config/ui/ui.go @@ -5,49 +5,54 @@ import ( "errors" "html/template" + "github.com/TwiN/gatus/v5/buildinfo" "github.com/TwiN/gatus/v5/storage" static "github.com/TwiN/gatus/v5/web" ) const ( - defaultTitle = "Health Dashboard | Gatus" - defaultDescription = "Gatus is an advanced automated status page that lets you monitor your applications and configure alerts to notify you if there's an issue" - defaultHeader = "Gatus" - defaultDashboardHeading = "Health Dashboard" - defaultDashboardSubheading = "Monitor the health of your endpoints in real-time" - defaultLogo = "" - defaultLink = "" - defaultCustomCSS = "" - defaultSortBy = "name" - defaultFilterBy = "none" + defaultTitle = "Health Dashboard | Gatus" + defaultDescription = "Gatus is an advanced automated status page that lets you monitor your applications and configure alerts to notify you if there's an issue" + defaultHeader = "Gatus" + defaultDashboardHeading = "Health Dashboard" + defaultDashboardSubheading = "Monitor the health of your endpoints in real-time" + defaultLogo = "" + defaultLink = "" + defaultCustomCSS = "" + defaultSortBy = "name" + defaultFilterBy = "none" ) var ( - defaultDarkMode = true + defaultDarkMode = true + defaultShowVersion = false ErrButtonValidationFailed = errors.New("invalid button configuration: missing required name or link") ErrInvalidDefaultSortBy = errors.New("invalid default-sort-by value: must be 'name', 'group', or 'health'") ErrInvalidDefaultFilterBy = errors.New("invalid default-filter-by value: must be 'none', 'failing', or 'unstable'") + ErrEmptyBuildVersion = errors.New("build version cannot be empty: This should never happen") ) // Config is the configuration for the UI of Gatus type Config struct { - Title string `yaml:"title,omitempty"` // Title of the page - Description string `yaml:"description,omitempty"` // Meta description of the page - DashboardHeading string `yaml:"dashboard-heading,omitempty"` // Dashboard Title between header and endpoints - DashboardSubheading string `yaml:"dashboard-subheading,omitempty"` // Dashboard Description between header and endpoints - Header string `yaml:"header,omitempty"` // Header is the text at the top of the page - Logo string `yaml:"logo,omitempty"` // Logo to display on the page - Link string `yaml:"link,omitempty"` // Link to open when clicking on the logo - Buttons []Button `yaml:"buttons,omitempty"` // Buttons to display below the header - CustomCSS string `yaml:"custom-css,omitempty"` // Custom CSS to include in the page - DarkMode *bool `yaml:"dark-mode,omitempty"` // DarkMode is a flag to enable dark mode by default - DefaultSortBy string `yaml:"default-sort-by,omitempty"` // DefaultSortBy is the default sort option ('name', 'group', 'health') - DefaultFilterBy string `yaml:"default-filter-by,omitempty"` // DefaultFilterBy is the default filter option ('none', 'failing', 'unstable') + Title string `yaml:"title,omitempty"` // Title of the page + Description string `yaml:"description,omitempty"` // Meta description of the page + DashboardHeading string `yaml:"dashboard-heading,omitempty"` // Dashboard Title between header and endpoints + DashboardSubheading string `yaml:"dashboard-subheading,omitempty"` // Dashboard Description between header and endpoints + Header string `yaml:"header,omitempty"` // Header is the text at the top of the page + Logo string `yaml:"logo,omitempty"` // Logo to display on the page + Link string `yaml:"link,omitempty"` // Link to open when clicking on the logo + Buttons []Button `yaml:"buttons,omitempty"` // Buttons to display below the header + CustomCSS string `yaml:"custom-css,omitempty"` // Custom CSS to include in the page + DarkMode *bool `yaml:"dark-mode,omitempty"` // DarkMode is a flag to enable dark mode by default + DefaultSortBy string `yaml:"default-sort-by,omitempty"` // DefaultSortBy is the default sort option ('name', 'group', 'health') + DefaultFilterBy string `yaml:"default-filter-by,omitempty"` // DefaultFilterBy is the default filter option ('none', 'failing', 'unstable') + ShowVersion *bool `yaml:"show-version,omitempty"` // ShowVersion is a flag to show build information in the footer ////////////////////////////////////////////// // Non-configurable - used for UI rendering // ////////////////////////////////////////////// - MaximumNumberOfResults int `yaml:"-"` // MaximumNumberOfResults to display on the page, it's not configurable because we're passing it from the storage config + MaximumNumberOfResults int `yaml:"-"` // MaximumNumberOfResults to display on the page, it's not configurable because we're passing it from the storage config + BuildVersion string `yaml:"-"` // BuildVersion of Gatus, it's not configurable because it is set at build time } func (cfg *Config) IsDarkMode() bool { @@ -73,6 +78,10 @@ func (btn *Button) Validate() error { // GetDefaultConfig returns a Config struct with the default values func GetDefaultConfig() *Config { + var buildversion string + if defaultShowVersion { // Only set version if exposing it to the frontend is enabled + buildversion = buildinfo.Get().Version + } return &Config{ Title: defaultTitle, Description: defaultDescription, @@ -85,7 +94,9 @@ func GetDefaultConfig() *Config { DarkMode: &defaultDarkMode, DefaultSortBy: defaultSortBy, DefaultFilterBy: defaultFilterBy, + ShowVersion: &defaultShowVersion, MaximumNumberOfResults: storage.DefaultMaximumNumberOfResults, + BuildVersion: buildversion, } } @@ -128,6 +139,12 @@ func (cfg *Config) ValidateAndSetDefaults() error { } else if cfg.DefaultFilterBy != "none" && cfg.DefaultFilterBy != "failing" && cfg.DefaultFilterBy != "unstable" { return ErrInvalidDefaultFilterBy } + if cfg.ShowVersion == nil { + cfg.ShowVersion = &defaultShowVersion + } + if *cfg.ShowVersion { // Only set version if exposing it to the frontend is enabled + cfg.BuildVersion = buildinfo.Get().Version + } for _, btn := range cfg.Buttons { if err := btn.Validate(); err != nil { return err diff --git a/config/ui/ui_test.go b/config/ui/ui_test.go index 12033b32b..7f18722b3 100644 --- a/config/ui/ui_test.go +++ b/config/ui/ui_test.go @@ -4,6 +4,8 @@ import ( "errors" "strconv" "testing" + + "github.com/TwiN/gatus/v5/buildinfo" ) func TestConfig_ValidateAndSetDefaults(t *testing.T) { @@ -16,6 +18,9 @@ func TestConfig_ValidateAndSetDefaults(t *testing.T) { Header: "", Logo: "", Link: "", + DefaultSortBy: "", + DefaultFilterBy: "", + ShowVersion: nil, } if err := cfg.ValidateAndSetDefaults(); err != nil { t.Error("expected no error, got", err.Error()) @@ -41,8 +46,15 @@ func TestConfig_ValidateAndSetDefaults(t *testing.T) { if cfg.DefaultFilterBy != defaultFilterBy { t.Errorf("expected defaultFilterBy to be %s, got %s", defaultFilterBy, cfg.DefaultFilterBy) } + if *cfg.ShowVersion != defaultShowVersion { + t.Errorf("expected ShowVersion to be %v, got %v", defaultShowVersion, *cfg.ShowVersion) + } + if len(cfg.BuildVersion) > 0 { + t.Errorf("expected BuildVersion to be empty, got %s", cfg.BuildVersion) + } }) t.Run("custom-values", func(t *testing.T) { + var showVersion = true cfg := &Config{ Title: "Custom Title", Description: "Custom Description", @@ -53,6 +65,7 @@ func TestConfig_ValidateAndSetDefaults(t *testing.T) { Link: "https://example.com", DefaultSortBy: "health", DefaultFilterBy: "failing", + ShowVersion: &showVersion, } if err := cfg.ValidateAndSetDefaults(); err != nil { t.Error("expected no error, got", err.Error()) @@ -84,6 +97,12 @@ func TestConfig_ValidateAndSetDefaults(t *testing.T) { if cfg.DefaultFilterBy != "failing" { t.Errorf("expected defaultFilterBy to be preserved, got %s", cfg.DefaultFilterBy) } + if *cfg.ShowVersion != showVersion { + t.Errorf("expected ShowVersion to be preserved, got %v", *cfg.ShowVersion) + } + if cfg.BuildVersion != buildinfo.Get().Version { + t.Errorf("expected BuildVersion to be %s, got %s", buildinfo.Get().Version, cfg.BuildVersion) + } }) t.Run("partial-custom-values", func(t *testing.T) { cfg := &Config{ @@ -172,6 +191,12 @@ func TestGetDefaultConfig(t *testing.T) { if defaultConfig.DefaultFilterBy != defaultFilterBy { t.Error("expected GetDefaultConfig() to return defaultFilterBy, got", defaultConfig.DefaultFilterBy) } + if *defaultConfig.ShowVersion != defaultShowVersion { + t.Error("expected GetDefaultConfig() to return defaultShowVersion, got", *defaultConfig.ShowVersion) + } + if len(defaultConfig.BuildVersion) > 0 { + t.Errorf("expected BuildVersion to be empty, got %s", defaultConfig.BuildVersion) + } } func TestConfig_ValidateAndSetDefaults_DefaultSortBy(t *testing.T) { diff --git a/main.go b/main.go index 13788fd10..ee2748a4e 100644 --- a/main.go +++ b/main.go @@ -7,6 +7,7 @@ import ( "syscall" "time" + "github.com/TwiN/gatus/v5/buildinfo" "github.com/TwiN/gatus/v5/config" "github.com/TwiN/gatus/v5/controller" "github.com/TwiN/gatus/v5/metrics" @@ -26,6 +27,8 @@ func main() { logr.Infof("Delaying start by %d seconds", delayInSeconds) time.Sleep(time.Duration(delayInSeconds) * time.Second) } + buildInfo := buildinfo.Get() + logr.Infof("Starting Gatus (version: %s, revision: %s, revision-date: %s)", buildInfo.Version, buildInfo.Revision, buildInfo.RevisionDate) configureLogging() cfg, err := loadConfiguration() if err != nil { diff --git a/metrics/metrics.go b/metrics/metrics.go index 027f6669a..85bbfe2ec 100644 --- a/metrics/metrics.go +++ b/metrics/metrics.go @@ -3,6 +3,7 @@ package metrics import ( "strconv" + "github.com/TwiN/gatus/v5/buildinfo" "github.com/TwiN/gatus/v5/config" "github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/config/suite" @@ -12,6 +13,7 @@ import ( const namespace = "gatus" // The prefix of the metrics var ( + staticBuildInfo *prometheus.GaugeVec resultTotal *prometheus.CounterVec resultDurationSeconds *prometheus.GaugeVec resultConnectedTotal *prometheus.CounterVec @@ -37,6 +39,9 @@ func UnregisterPrometheusMetrics() { } // Unregister all metrics if they exist + if staticBuildInfo != nil { + currentRegisterer.Unregister(staticBuildInfo) + } if resultTotal != nil { currentRegisterer.Unregister(resultTotal) } @@ -88,6 +93,15 @@ func InitializePrometheusMetrics(cfg *config.Config, reg prometheus.Registerer) currentRegisterer = reg extraLabels := cfg.GetUniqueExtraMetricLabels() + staticBuildInfo = prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Namespace: namespace, + Name: "build_info", + Help: "Build information about this instance", + }, []string{"version", "revision", "revision_date", "go_version"}) + reg.MustRegister(staticBuildInfo) + + info := buildinfo.Get() + staticBuildInfo.WithLabelValues(info.Version, info.Revision, info.RevisionDate, info.GoVersion).Set(1) resultTotal = prometheus.NewCounterVec(prometheus.CounterOpts{ Namespace: namespace, Name: "results_total", diff --git a/metrics/metrics_test.go b/metrics/metrics_test.go index 951256d80..fca02ec05 100644 --- a/metrics/metrics_test.go +++ b/metrics/metrics_test.go @@ -2,6 +2,7 @@ package metrics import ( "bytes" + "runtime" "testing" "time" @@ -33,6 +34,9 @@ func TestInitializePrometheusMetrics(t *testing.T) { reg := prometheus.NewRegistry() InitializePrometheusMetrics(cfgWithExtras, reg) // Metrics variables should be non-nil + if staticBuildInfo == nil { + t.Error("staticBuildInfo metric not initialized") + } if resultTotal == nil { t.Error("resultTotal metric not initialized") } @@ -63,6 +67,21 @@ func TestInitializePrometheusMetrics(t *testing.T) { _ = resultTotal.WithLabelValues("k", "g", "n", "ty", "true", "fval", "hval") } +func TestStaticBuildInfoMetric(t *testing.T) { + reg := prometheus.NewRegistry() + InitializePrometheusMetrics(&config.Config{}, reg) + goVersion := runtime.Version() + expected := ` +# HELP gatus_build_info Build information about this instance +# TYPE gatus_build_info gauge +gatus_build_info{go_version="` + goVersion + `",revision="unknown",revision_date="unknown",version="development"} 1 +` + err := testutil.GatherAndCompare(reg, bytes.NewBufferString(expected), "gatus_build_info") + if err != nil { + t.Errorf("metrics export does not match expected:\n%v", err) + } +} + // TestPublishMetricsForEndpoint_withExtraLabels ensures extraLabels are included in the exported metrics. func TestPublishMetricsForEndpoint_withExtraLabels(t *testing.T) { // Only test one label set per process due to Prometheus registry limits. diff --git a/web/app/public/index.html b/web/app/public/index.html index 292029a99..1ddde4213 100644 --- a/web/app/public/index.html +++ b/web/app/public/index.html @@ -3,7 +3,7 @@