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
34 changes: 10 additions & 24 deletions .goreleaser-docker-only.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,8 @@ archives:

dockers:
- image_templates:
- "creativeprojects/resticprofile:latest-amd64"
- "creativeprojects/resticprofile:{{ .RawVersion }}-amd64"
- "ghcr.io/creativeprojects/resticprofile:latest-amd64"
- "ghcr.io/creativeprojects/resticprofile:{{ .RawVersion }}-amd64"
- "ghcr.io/{{ .Env.GITHUB_REPOSITORY }}:latest-amd64"
- "ghcr.io/{{ .Env.GITHUB_REPOSITORY }}:{{ .RawVersion }}-amd64"
ids:
- resticprofile_targz
use: buildx
Expand All @@ -52,10 +50,8 @@ dockers:
- "--build-arg=ARCH=amd64"

- image_templates:
- "creativeprojects/resticprofile:latest-arm64v8"
- "creativeprojects/resticprofile:{{ .RawVersion }}-arm64v8"
- "ghcr.io/creativeprojects/resticprofile:latest-arm64v8"
- "ghcr.io/creativeprojects/resticprofile:{{ .RawVersion }}-arm64v8"
- "ghcr.io/{{ .Env.GITHUB_REPOSITORY }}:latest-arm64v8"
- "ghcr.io/{{ .Env.GITHUB_REPOSITORY }}:{{ .RawVersion }}-arm64v8"
ids:
- resticprofile_targz
use: buildx
Expand All @@ -75,25 +71,15 @@ dockers:
- "--build-arg=ARCH=arm64"

docker_manifests:
- name_template: creativeprojects/resticprofile:{{ .RawVersion }}
- name_template: ghcr.io/{{ .Env.GITHUB_REPOSITORY }}:{{ .RawVersion }}
image_templates:
- creativeprojects/resticprofile:{{ .RawVersion }}-amd64
- creativeprojects/resticprofile:{{ .RawVersion }}-arm64v8
- ghcr.io/{{ .Env.GITHUB_REPOSITORY }}:{{ .RawVersion }}-amd64
- ghcr.io/{{ .Env.GITHUB_REPOSITORY }}:{{ .RawVersion }}-arm64v8

- name_template: creativeprojects/resticprofile:latest
- name_template: ghcr.io/{{ .Env.GITHUB_REPOSITORY }}:latest
image_templates:
- creativeprojects/resticprofile:latest-amd64
- creativeprojects/resticprofile:latest-arm64v8

- name_template: ghcr.io/creativeprojects/resticprofile:{{ .RawVersion }}
image_templates:
- ghcr.io/creativeprojects/resticprofile:{{ .RawVersion }}-amd64
- ghcr.io/creativeprojects/resticprofile:{{ .RawVersion }}-arm64v8

- name_template: ghcr.io/creativeprojects/resticprofile:latest
image_templates:
- ghcr.io/creativeprojects/resticprofile:latest-amd64
- ghcr.io/creativeprojects/resticprofile:latest-arm64v8
- ghcr.io/{{ .Env.GITHUB_REPOSITORY }}:latest-amd64
- ghcr.io/{{ .Env.GITHUB_REPOSITORY }}:latest-arm64v8

release:
disable: true
12 changes: 12 additions & 0 deletions commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,18 @@ func getOwnCommands() []ownCommand {
hide: true,
experimental: true,
},
{
name: "serve-metrics",
description: "serve the profile's prometheus-save-to-file (and Go runtime metrics) on /metrics",
action: serveMetricsCommand,
needConfiguration: true,
noProfile: false,
hide: true,
experimental: true,
flags: map[string]string{
"--metrics-port": "port to expose /metrics on (overrides RESTICPROFILE_METRICS_PORT)",
},
},
}
}

Expand Down
86 changes: 86 additions & 0 deletions commands_schedule.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,11 @@ import (
"errors"
"fmt"
"maps"
"os"
"os/signal"
"slices"
"strings"
"syscall"

"github.com/creativeprojects/clog"
"github.com/creativeprojects/resticprofile/config"
Expand Down Expand Up @@ -72,9 +75,92 @@ func createSchedule(ctx commandContext) error {
}
}

// Optional: if --metrics-port is set, keep the process alive and serve
// the profiles' prometheus-save-to-file on /metrics. This lets the schedule
// command double as a metrics sidecar when running under crond in a container.
if err := blockForMetricsServer(ctx); err != nil {
return err
}

return nil
}

// blockForMetricsServer starts a /metrics HTTP server for the prometheus-save-to-file
// of the selected profiles, then blocks until SIGINT/SIGTERM. Returns nil immediately
// (and the function returns) when --metrics-port is not set or no profile has a
// prometheus-save-to-file configured. A warning is logged in the latter case so the
// user knows metrics were requested but no file is available.
func blockForMetricsServer(ctx commandContext) error {
if ctx.flags.metricsPort <= 0 {
return nil
}

metricFiles := collectPrometheusSaveToFiles(ctx)
if len(metricFiles) == 0 {
clog.Warningf("metrics server not started: no profile has prometheus-save-to-file set")
return nil
}

quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
defer signal.Stop(quit)

errCh := make(chan error, len(metricFiles))
for _, file := range metricFiles {
file := file
go func() {
if err := newMetricsServer(ctx.flags.metricsPort, file).run(quit); err != nil {
clog.Errorf("metrics server failed for %q: %v", file, err)
errCh <- err
}
}()
}

clog.Infof("schedule command will keep the process alive for the metrics server; send SIGINT/SIGTERM to stop")
select {
case <-quit:
clog.Info("shutting down metrics server (schedule)")
return nil
case err := <-errCh:
// best-effort: close quit so all running goroutines shut down
close(quit)
return err
}
}

// collectPrometheusSaveToFiles returns the unique non-empty prometheus-save-to-file
// paths of all profiles selected by the schedule command (--all or a specific profile).
// If a group is selected, the prometheus-save-to-file of each member profile is used.
func collectPrometheusSaveToFiles(ctx commandContext) []string {
seen := make(map[string]struct{})
var files []string
args := ctx.request.arguments
add := func(name string) {
profile, err := ctx.config.GetProfile(name)
if err != nil || profile == nil || profile.PrometheusSaveToFile == "" {
return
}
if _, ok := seen[profile.PrometheusSaveToFile]; ok {
return
}
seen[profile.PrometheusSaveToFile] = struct{}{}
files = append(files, profile.PrometheusSaveToFile)
}

for _, name := range selectProfilesAndGroups(ctx.config, ctx.request.profile, args) {
if ctx.config.HasProfile(name) {
add(name)
continue
}
if group, err := ctx.config.GetProfileGroup(name); err == nil && group != nil {
for _, member := range group.Profiles {
add(member)
}
}
}
return files
}

func removeSchedule(ctx commandContext) error {
var err error
c := ctx.config
Expand Down
95 changes: 95 additions & 0 deletions commands_schedule_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -298,3 +298,98 @@ func TestCreateScheduleOverwriteExistingIntegrationUsingCrontab(t *testing.T) {
assert.NotContains(t, output.String(), "Original form: *-*-* *:10,40:00") // previous one
t.Log(output.String())
}

func TestCollectPrometheusSaveToFiles(t *testing.T) {
const configYAML = `
version: "2"

groups:
group-a:
profiles:
- alpha
- beta

profiles:
alpha:
prometheus-save-to-file: /var/metrics/alpha.prom
beta:
prometheus-save-to-file: /var/metrics/beta.prom
gamma:
prometheus-save-to-file: /var/metrics/shared.prom
delta:
prometheus-save-to-file: /var/metrics/shared.prom
epsilon: {}
`
testCases := []struct {
name string
arguments []string
profile string
expected []string
matcher func(t assert.TestingT, expected, actual interface{}, msgAndArgs ...interface{}) bool
}{
{
name: "single profile",
arguments: []string{"schedule"},
profile: "alpha",
expected: []string{"/var/metrics/alpha.prom"},
matcher: assert.Equal,
},
{
name: "all profiles dedups shared file",
arguments: []string{"schedule", "--all"},
profile: "",
// Order is non-deterministic (viper map iteration); assert set
// membership via ElementsMatch so the test isn't order-sensitive.
expected: []string{"/var/metrics/shared.prom", "/var/metrics/alpha.prom", "/var/metrics/beta.prom"},
matcher: assert.ElementsMatch,
},
{
name: "group aggregates member files",
arguments: []string{"schedule"},
profile: "group-a",
expected: []string{"/var/metrics/alpha.prom", "/var/metrics/beta.prom"},
matcher: assert.ElementsMatch,
},
{
name: "profile without prometheus-save-to-file yields nothing",
arguments: []string{"schedule"},
profile: "epsilon",
expected: nil,
matcher: assert.Equal,
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
cfg, err := config.Load(bytes.NewBufferString(configYAML), config.FormatYAML, config.WithConfigFile("config.yaml"))
require.NoError(t, err)

ctx := commandContext{
Context: Context{
config: cfg,
request: Request{
profile: tc.profile,
arguments: tc.arguments,
},
},
}

got := collectPrometheusSaveToFiles(ctx)
tc.matcher(t, tc.expected, got)
})
}
}

func TestBlockForMetricsServerDisabledByDefault(t *testing.T) {
cfg, err := config.Load(bytes.NewBufferString("version: \"2\"\nprofiles:\n alpha:\n prometheus-save-to-file: /tmp/x.prom\n"), config.FormatYAML, config.WithConfigFile("config.yaml"))
require.NoError(t, err)

ctx := commandContext{
Context: Context{
config: cfg,
request: Request{profile: "alpha"},
flags: commandLineFlags{metricsPort: 0}, // explicitly disabled
},
}
assert.NoError(t, blockForMetricsServer(ctx))
}
23 changes: 23 additions & 0 deletions docs/content/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,29 @@ title: Release Notes
weight: 8
---

# v0.34.0 (unreleased)

## New Features

### Prometheus metrics endpoint [EXPERIMENTAL]

resticprofile can now expose the contents of a profile's `prometheus-save-to-file` on an HTTP endpoint, alongside the Go runtime metrics, so a sidecar Prometheus can scrape the last backup status from `schedule` and `serve-metrics` commands.

Two ways to use it:

- `resticprofile --metrics-port <port> schedule --all` — run `schedule` as usual; once the cron entries are installed the process blocks on SIGINT/SIGTERM, serving `/metrics` for every selected profile and group (textfile paths are de-duplicated).
- `resticprofile --metrics-port <port> <profile> serve-metrics` — long-running sidecar serving a single profile's textfile. Useful when you can't or don't want to pass `--metrics-port` to the scheduler.

Both honour `RESTICPROFILE_METRICS_PORT` and the `--metrics-port` flag is hidden from `--help` until the feature is stabilized.

Response contract:

- `200 OK` — runtime metrics + textfile body
- `503 Service Unavailable` — textfile not yet created (e.g. first backup has not run). Prometheus treats this as `up == 0` rather than logging a 404 every scrape.
- `500 Internal Server Error` — other I/O errors

The `/metrics` endpoint binds on `0.0.0.0:<port>` for compatibility with container environments using `network_mode: host`.

# v0.33.0 (2026-04-04)

## 🌸 Easter release 🥚
Expand Down
44 changes: 43 additions & 1 deletion docs/content/monitoring/prometheus.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,4 +229,46 @@ root:
{{< /tabs >}}


This adds the `host` label to all your metrics.
This adds the `host` label to all your metrics.

## Exposing metrics over HTTP [EXPERIMENTAL]

In addition to the file-based and push-based options, resticprofile can serve the latest metrics on an HTTP endpoint. The endpoint combines the Go runtime collector with the profile's `prometheus-save-to-file` body so a single sidecar can be scraped by Prometheus, Node Exporter's `--collector.textfile.directory`, or anything else that understands the Prometheus text exposition format.

The feature is opt-in and hidden from `--help` until it stabilises. Enable it via the `--metrics-port` flag (or the `RESTICPROFILE_METRICS_PORT` environment variable) and select one of the following integration points:

### `schedule` keeps the process alive and serves `/metrics`

When `resticprofile` is used as the scheduler under `crond` (e.g. inside a container), it can double as a metrics sidecar. The scheduler registers all cron jobs as usual, then blocks on SIGINT/SIGTERM while serving `/metrics` for each profile that has `prometheus-save-to-file` set. Textfile paths are de-duplicated across profiles and groups, so several profiles writing to the same file share a single scrape target.

```sh
resticprofile --metrics-port 19302 schedule --all &
crond -f
```

### `serve-metrics` as a stand-alone sidecar

If you don't use `schedule` (or already have a separate scheduler), run a dedicated process:

```sh
resticprofile --metrics-port 19302 default serve-metrics
```

### Response contract

- `200 OK` — Go runtime metrics followed by the textfile body.
- `503 Service Unavailable` — the textfile does not exist yet (e.g. the first backup has not run). Prometheus reports the target as `up == 0` without logging a 404 on every scrape.
- `500 Internal Server Error` — other I/O errors.

The server binds on `0.0.0.0:<port>` for `network_mode: host` containers.

### Scrape configuration

```yaml
scrape_configs:
- job_name: resticprofile
static_configs:
- targets: ['<host>:19302']
labels:
host: backup-server
```
5 changes: 5 additions & 0 deletions flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ type commandLineFlags struct {
ignoreOnBattery int
usagesHelp string
remote string // url of the remote server to download configuration files from
metricsPort int // port to expose /metrics on (0 = disabled)
}

func envValueOverride[T any](defaultValue T, keys ...string) T {
Expand Down Expand Up @@ -94,6 +95,7 @@ func loadFlags(args []string) (*pflag.FlagSet, commandLineFlags, error) {
wait: envValueOverride(false, "RESTICPROFILE_WAIT"),
ignoreOnBattery: envValueOverride(0, "RESTICPROFILE_IGNORE_ON_BATTERY"),
remote: envValueOverride("", "RESTICPROFILE_REMOTE"),
metricsPort: envValueOverride(0, "RESTICPROFILE_METRICS_PORT"),
}

flagset.BoolVarP(&flags.help, "help", "h", flags.help, "display this help")
Expand All @@ -119,6 +121,9 @@ func loadFlags(args []string) (*pflag.FlagSet, commandLineFlags, error) {
// keep the "remote" flag hidden for now
_ = flagset.MarkHidden("remote")

flagset.IntVar(&flags.metricsPort, "metrics-port", flags.metricsPort, "port to expose /metrics on (overrides RESTICPROFILE_METRICS_PORT; 0 disables)")
_ = flagset.MarkHidden("metrics-port")

flagset.SetNormalizeFunc(func(f *pflag.FlagSet, name string) pflag.NormalizedName {
switch name {
case "profile":
Expand Down
Loading