diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..ce799f56 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,31 @@ +version: 2 +updates: + # Go module dependencies. Minor/patch bumps are grouped into a single weekly + # PR to cut down on noise; major bumps stay as individual PRs since they tend + # to need code changes and a real review. + - package-ecosystem: gomod + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 10 + labels: + - dependencies + - go + groups: + go-minor-and-patch: + update-types: + - minor + - patch + + # Keep GitHub Actions used in workflows up to date. + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + labels: + - dependencies + groups: + github-actions: + update-types: + - minor + - patch diff --git a/.github/workflows/dependabot-auto-merge.yml b/.github/workflows/dependabot-auto-merge.yml new file mode 100644 index 00000000..a9be8bfb --- /dev/null +++ b/.github/workflows/dependabot-auto-merge.yml @@ -0,0 +1,29 @@ +name: Dependabot auto-merge + +# Enables auto-merge for Dependabot minor/patch bumps so they merge on their own +# once all required status checks pass. Major bumps are left for manual review. +# +# Requires, in repo settings: +# - "Allow auto-merge" enabled +# - a branch protection rule on main requiring the CI checks to pass +on: pull_request_target + +permissions: + contents: write + pull-requests: write + +jobs: + auto-merge: + runs-on: ubuntu-latest + if: github.actor == 'dependabot[bot]' + steps: + - name: Fetch Dependabot metadata + id: meta + uses: dependabot/fetch-metadata@v2 + + - name: Enable auto-merge for minor and patch updates + if: steps.meta.outputs.update-type == 'version-update:semver-minor' || steps.meta.outputs.update-type == 'version-update:semver-patch' + run: gh pr merge --auto --squash "$PR_URL" + env: + PR_URL: ${{ github.event.pull_request.html_url }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..84ee4213 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,35 @@ +name: Unit tests + +# Fast feedback gate: compiles the code (go vet) and runs the unit tests in +# tests/unit. Far quicker than the integration "validate" jobs, so build breaks +# and obvious regressions surface in a couple of minutes. +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +env: + CGO_ENABLED: "1" # podman bindings require CGO + BUILDTAGS: "containers_image_openpgp gssapi providerless netgo osusergo exclude_graphdriver_btrfs" + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Install build dependencies + run: | + sudo apt-get update + sudo apt-get install -y libgpgme-dev libdevmapper-dev libseccomp-dev pkg-config + + - name: go vet + run: go vet -tags "$BUILDTAGS" ./cmd/... ./pkg/... + + - name: Unit tests + run: go test -tags "$BUILDTAGS" ./tests/unit/... ./pkg/... diff --git a/README.md b/README.md index 737f82af..3492b1fe 100644 --- a/README.md +++ b/README.md @@ -166,3 +166,48 @@ watch ls -al /tmp/hello.txt ``` podman stop colors1 colors2 fetchit && podman rm colors1 colors2 && podman volume rm fetchit-volume ``` + +## Health and status endpoint + +FetchIt can expose a small HTTP server for liveness checks and reconciliation +status. It is off by default; set `FETCHIT_STATUS_ADDR` to the address to listen +on to enable it: + +``` +podman run -d --rm --name fetchit \ + -e FETCHIT_STATUS_ADDR=:8080 \ + -p 8080:8080 \ + -v fetchit-volume:/opt \ + -v $HOME/.fetchit:/opt/mount \ + -v /run/user/$(id -u)/podman//podman.sock:/run/podman/podman.sock \ + --security-opt label=disable \ + quay.io/fetchit/fetchit:latest +``` + +A host-less value like `:8080` binds all interfaces, which is what container +port publishing (`-p 8080:8080`) needs. For a bare-metal install where you only +want local access, set `FETCHIT_STATUS_ADDR=127.0.0.1:8080`. + +- `GET /healthz` returns `200 OK` while the process is alive (use as a container + healthcheck or readiness probe). +- `GET /status` returns JSON describing uptime and each configured method, its + schedule, run count, and last run time: + +``` +curl -s localhost:8080/status +{ + "status": "running", + "startedAt": "2026-06-13T15:04:05Z", + "uptimeSeconds": 312, + "methods": [ + { + "kind": "raw", + "name": "raw-ex", + "url": "https://github.com/containers/fetchit", + "schedule": "*/1 * * * *", + "runs": 5, + "lastRun": "2026-06-13T15:09:05Z" + } + ] +} +``` diff --git a/pkg/engine/fetchit.go b/pkg/engine/fetchit.go index bb6f5a36..f1627c24 100644 --- a/pkg/engine/fetchit.go +++ b/pkg/engine/fetchit.go @@ -348,6 +348,7 @@ func getMethodTargetScheds(targetConfigs []*TargetConfig, fetchit *Fetchit) *Fet } func (f *Fetchit) RunTargets() { + startStatusServer() for method := range f.methodTargetScheds { // ConfigReload, PodmanAutoUpdateAll, Image, Prune methods do not include git URL if method.GetTarget().url != "" { @@ -367,7 +368,12 @@ func (f *Fetchit) RunTargets() { defer cancel() mt := method.GetKind() logger.Infof("Processing git target: %s Method: %s Name: %s", method.GetTarget().url, mt, method.GetName()) - s.Cron(schedInfo.schedule).Tag(mt).Do(method.Process, ctx, f.conn, skew) + status.register(method, schedInfo.schedule) + m := method + s.Cron(schedInfo.schedule).Tag(mt).Do(func(ctx, conn context.Context, skew int) { + status.recordRun(m) + m.Process(ctx, conn, skew) + }, ctx, f.conn, skew) s.StartImmediately() } s.StartAsync() diff --git a/pkg/engine/status.go b/pkg/engine/status.go new file mode 100644 index 00000000..8e5076bb --- /dev/null +++ b/pkg/engine/status.go @@ -0,0 +1,136 @@ +package engine + +import ( + "encoding/json" + "net/http" + "os" + "sort" + "sync" + "time" +) + +// statusAddrEnv, when set (e.g. ":8080"), makes fetchit serve a small HTTP +// status server exposing /healthz and /status. Left unset, no port is opened. +const statusAddrEnv = "FETCHIT_STATUS_ADDR" + +// methodStatus is the per-method reconciliation state surfaced at /status. +type methodStatus struct { + Kind string `json:"kind"` + Name string `json:"name"` + URL string `json:"url,omitempty"` + Schedule string `json:"schedule"` + Runs int `json:"runs"` + LastRun *time.Time `json:"lastRun,omitempty"` +} + +type statusRegistry struct { + mu sync.Mutex + started time.Time + methods map[string]*methodStatus +} + +var status = &statusRegistry{methods: make(map[string]*methodStatus)} + +func statusKey(m Method) string { + return m.GetKind() + "/" + m.GetName() + "/" + m.GetTarget().url +} + +// register records a scheduled method so it shows up at /status before its +// first run. +func (r *statusRegistry) register(m Method, schedule string) { + r.mu.Lock() + defer r.mu.Unlock() + if r.started.IsZero() { + r.started = time.Now() + } + r.methods[statusKey(m)] = &methodStatus{ + Kind: m.GetKind(), + Name: m.GetName(), + URL: m.GetTarget().url, + Schedule: schedule, + } +} + +// recordRun stamps the most recent execution of a method. +func (r *statusRegistry) recordRun(m Method) { + r.mu.Lock() + defer r.mu.Unlock() + s, ok := r.methods[statusKey(m)] + if !ok { + return + } + now := time.Now() + s.Runs++ + s.LastRun = &now +} + +func (r *statusRegistry) writeJSON(w http.ResponseWriter) { + r.mu.Lock() + // Copy by value under the lock so concurrent recordRun/register calls + // cannot mutate what we encode below. + ms := make([]methodStatus, 0, len(r.methods)) + for _, s := range r.methods { + ms = append(ms, *s) + } + started := r.started + r.mu.Unlock() + + sort.Slice(ms, func(i, j int) bool { + if ms[i].Kind != ms[j].Kind { + return ms[i].Kind < ms[j].Kind + } + return ms[i].Name < ms[j].Name + }) + + // Before the first method registers, started is zero; report a sane state + // instead of an uptime measured from year 1. + state := "running" + var uptime int64 + if started.IsZero() { + state = "initializing" + } else { + uptime = int64(time.Since(started).Seconds()) + } + + resp := struct { + Status string `json:"status"` + StartedAt time.Time `json:"startedAt"` + UptimeSeconds int64 `json:"uptimeSeconds"` + Methods []methodStatus `json:"methods"` + }{ + Status: state, + StartedAt: started, + UptimeSeconds: uptime, + Methods: ms, + } + + w.Header().Set("Content-Type", "application/json") + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + if err := enc.Encode(resp); err != nil { + logger.Warnf("status: failed to encode response: %v", err) + } +} + +// startStatusServer launches the status/health HTTP server in a goroutine if +// FETCHIT_STATUS_ADDR is set. It is a no-op otherwise. +func startStatusServer() { + addr := os.Getenv(statusAddrEnv) + if addr == "" { + return + } + mux := http.NewServeMux() + mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok\n")) + }) + mux.HandleFunc("/status", func(w http.ResponseWriter, _ *http.Request) { + status.writeJSON(w) + }) + go func() { + logger.Infof("Status server listening on %s (/healthz, /status)", addr) + if err := http.ListenAndServe(addr, mux); err != nil { + logger.Errorf("status server error: %v", err) + } + }() +}