diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f09e0ce --- /dev/null +++ b/.dockerignore @@ -0,0 +1,31 @@ +# Include any files or directories that you don't want to be copied to your +# container here (e.g., local build artifacts, temporary files, etc.). +# +# For more help, visit the .dockerignore file reference guide at +# https://docs.docker.com/go/build-context-dockerignore/ + +**/.DS_Store +**/.classpath +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/bin +**/charts +**/docker-compose* +**/compose.y*ml +**/Dockerfile* +.github +LICENSE.md +README.md +SECURITY.md +Makefile +supercronic diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a1d4d92..213c1bc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,6 +5,7 @@ on: branches: [master] pull_request: branches: [master] + workflow_dispatch: jobs: build: @@ -15,25 +16,28 @@ jobs: go-version: [1.24.4] steps: - - name: install golang - uses: actions/setup-go@v5 - with: - go-version: ${{ matrix.go-version }} - - - name: install bats - run: | - git clone https://github.com/bats-core/bats-core.git --branch v1.11.0 --depth 1 "${HOME}/bats" - echo "${HOME}/bats/bin" >> $GITHUB_PATH - - - name: install govulncheck - run: | - go install golang.org/x/vuln/cmd/govulncheck@latest - - - name: checkout code - uses: actions/checkout@v4 - - - name: run tests - run: make test - - - name: run vuln check - run: make vulncheck + - name: install golang + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go-version }} + + - name: Setup Bats and bats libs + id: setup-bats + uses: bats-core/bats-action@3.0.0 + + - name: install govulncheck + run: | + go install golang.org/x/vuln/cmd/govulncheck@latest + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: checkout code + uses: actions/checkout@v4 + + - name: run tests + run: make test + env: + BATS_LIB_PATH: ${{ steps.setup-bats.outputs.lib-path }} + - name: run vuln check + run: make vulncheck diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3625c14 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,48 @@ +# syntax=docker/dockerfile:1 + +# Create a stage for building the application. +ARG GO_VERSION=1.24.4 +FROM --platform=$BUILDPLATFORM golang:${GO_VERSION} AS build +WORKDIR /src + +RUN --mount=type=cache,target=/go/pkg/mod/ \ + --mount=type=bind,source=go.sum,target=go.sum \ + --mount=type=bind,source=go.mod,target=go.mod \ + go mod download -x + +# This is the architecture you're building for, which is passed in by the builder. +# Placing it here allows the previous steps to be cached across architectures. +ARG TARGETARCH +ARG VERSION="" + +RUN --mount=type=cache,target=/go/pkg/mod/ \ + --mount=type=bind,target=. \ + CGO_ENABLED=0 GOARCH=$TARGETARCH \ + go build -ldflags "-X main.Version=${VERSION}" \ + -o /bin/supercronic . + +################################################################################ +FROM alpine:latest AS final + +RUN --mount=type=cache,target=/var/cache/apk \ + apk --update add \ + ca-certificates \ + tzdata \ + && \ + update-ca-certificates + +# Create a non-privileged user that the app will run under. +# See https://docs.docker.com/go/dockerfile-user-best-practices/ +ARG UID=10001 +RUN adduser \ + --disabled-password \ + --gecos "" \ + --system \ + --no-create-home \ + --uid "${UID}" \ + supercronic +USER supercronic + +COPY --from=build /bin/supercronic /bin/ + +ENTRYPOINT [ "/bin/supercronic" ] diff --git a/Makefile b/Makefile index 7df77ee..8ff9b2c 100644 --- a/Makefile +++ b/Makefile @@ -7,9 +7,13 @@ deps: go mod vendor .PHONY: build -build: $(GOFILES) +build: go build -ldflags "-X main.Version=${VERSION}" +.PHONY: docker-build +docker-build: + docker build -t supercronic:${VERSION} --build-arg VERSION=${VERSION} . + .PHONY: unit unit: go test -v -race $$(go list ./... | grep -v /vendor/) @@ -17,7 +21,7 @@ unit: .PHONY: integration integration: VERSION=v1337 -integration: build +integration: build docker-build bats integration .PHONY: test diff --git a/README.md b/README.md index 68bf7f0..f5ee254 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ implementations are ill-suited for container environments: - They often try to send their logs to syslog. This conveniently provides centralized logging when a syslog server is running, but with containers, simply logging to `stdout` or `stderr` is preferred. +- Automate reaping [zombie processes](https://en.wikipedia.org/wiki/Zombie_process) when running as PID 1, which may cause them to accumulate in the process table, consuming system resources. Finally, they are often quiet, making these issues difficult to understand and debug! diff --git a/cronexpr/cronexpr/go.mod b/cronexpr/cronexpr/go.mod index 1b1121a..415f0b1 100644 --- a/cronexpr/cronexpr/go.mod +++ b/cronexpr/cronexpr/go.mod @@ -1,7 +1,7 @@ module github.com/aptible/supercronic/cronexpr/cronexpr -go 1.18 +go 1.24.4 replace github.com/aptible/supercronic => ../../ -require github.com/aptible/supercronic v0.0.0-00010101000000-000000000000 +require github.com/aptible/supercronic v0.2.33 diff --git a/go.mod b/go.mod index 7e81ae1..0a76e4c 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/aptible/supercronic -go 1.23.0 +go 1.24.4 require ( github.com/evalphobia/logrus_sentry v0.8.2 diff --git a/integration/test.bats b/integration/test.bats index 8eb2060..f4c0695 100755 --- a/integration/test.bats +++ b/integration/test.bats @@ -7,9 +7,15 @@ function run_supercronic() { "${BATS_TEST_DIRNAME}/../supercronic" ${SUPERCRONIC_ARGS:-} "$crontab" 2>&1 } -setup () { +setup() { WORK_DIR="$(mktemp -d)" export WORK_DIR + + export BATS_LIB_PATH=${BATS_LIB_PATH:-"/usr/lib"} + bats_load_library bats-assert + bats_load_library bats-support + # mock version + export VERSION=v1337 } teardown() { @@ -18,7 +24,7 @@ teardown() { wait_for() { for i in $(seq 0 50); do - if "$@" > /dev/null 2>&1; then + if "$@" >/dev/null 2>&1; then return 0 fi sleep 0.1 @@ -28,8 +34,8 @@ wait_for() { } @test "it prints the version" { - run "${BATS_TEST_DIRNAME}/../supercronic" -version - [[ "$output" =~ ^v1337$ ]] + run "${BATS_TEST_DIRNAME}/../supercronic" -version + assert_output $VERSION } @test "it starts" { @@ -108,33 +114,31 @@ wait_for() { } @test "it run as pid 1 and reap zombie process" { - out="${WORK_DIR}/zombie-crontab-out" + run timeout 5s docker run \ + -v "${BATS_TEST_DIRNAME}/zombie.crontab":/test.crontab \ + --rm supercronic:${VERSION} /test.crontab - # run in new process namespace - sudo timeout 10s unshare --fork --pid --mount-proc \ - ${BATS_TEST_DIRNAME}/../supercronic "${BATS_TEST_DIRNAME}/zombie.crontab" >"$out" 2>&1 & - local pid=$! - sleep 3 + assert_equal $status 124 # timeout exit code - kill -TERM ${pid} # todo: use other method to detect zombie cleanup - wait_for grep "reaper cleanup: pid=" "$out" + assert_line --partial 'reaping dead processes' + assert_line --partial "reaper cleanup: pid=" } - @test "it run as pid 1 and normal crontab no error" { - out="${WORK_DIR}/normal-crontab-out" - # sleep 30 seconds occur found bug # FIXME: other way to detect - sudo timeout 30s unshare --fork --pid --mount-proc \ - "${BATS_TEST_DIRNAME}/../supercronic" "${BATS_TEST_DIRNAME}/normal.crontab" >"$out" 2>&1 & # https://github.com/aptible/supercronic/issues/171 - local pid=$! - local foundErr - - sleep 29.5 - kill -TERM ${pid} - grep "waitid: no child processes" "$out" && foundErr=1 - [[ $foundErr != 1 ]] + run timeout 30s docker run \ + -v "${BATS_TEST_DIRNAME}/normal.crontab":/normal.crontab \ + --rm supercronic:${VERSION} /normal.crontab + + assert_equal $status 124 # timeout exit code + + refute_line --partial 'waitid: no child processes' + assert_line --partial 'reaping dead processes' + assert_line --partial 'msg=1' # normal output + refute_line --partial 'failed' + # https://github.com/aptible/supercronic/issues/177 + refute_line --partial 'no such file or directory' } diff --git a/main.go b/main.go index 6b4c508..6ab051e 100644 --- a/main.go +++ b/main.go @@ -123,8 +123,9 @@ func main() { forkExec() return } - - logrus.Warn("process reaping disabled, not pid 1") + // warning may confuse user + // https://github.com/aptible/supercronic/issues/183 + logrus.Info("process reaping disabled, not pid 1.It's safe to ignore") } crontabFileName := flag.Args()[0] @@ -184,7 +185,7 @@ func main() { defer func() { if err := promServerShutdownClosure(); err != nil { - logrus.Fatalf("prometheus http shutdown failed: %s", err.Error()) + logrus.Errorf("prometheus http shutdown failed: %s", err.Error()) } }() } diff --git a/prometheus_metrics/prommetrics.go b/prometheus_metrics/prommetrics.go index c225895..bf51e01 100644 --- a/prometheus_metrics/prommetrics.go +++ b/prometheus_metrics/prommetrics.go @@ -2,6 +2,7 @@ package prometheus_metrics import ( "context" + "errors" "fmt" "net" "net/http" @@ -55,7 +56,7 @@ func NewPrometheusMetrics() PrometheusMetrics { pm.CronsSuccessCounter = *prometheus.NewCounterVec( prometheus.CounterOpts{ Name: genMetricName("successful_executions"), - Help: "count of successul cron executions", + Help: "count of successful cron executions", }, cronLabels, ) @@ -140,17 +141,23 @@ func InitHTTPServer(listenAddr string, shutdownContext context.Context) (func() http.Handle("/metrics", promhttp.Handler()) http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte(` + _, err := w.Write([]byte(` Supercronic

Supercronic

Metrics

`)) + if err != nil { + logrus.Warnf("failed to write response on '/': %s", err.Error()) + } }) http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte(`OK`)) + _, err := w.Write([]byte(`OK`)) + if err != nil { + logrus.Warnf("failed to write response on '/health': %s", err.Error()) + } }) shutdownClosure := func() error { @@ -164,7 +171,9 @@ func InitHTTPServer(listenAddr string, shutdownContext context.Context) (func() go func() { if err := promSrv.Serve(listener); err != nil { - logrus.Fatalf("prometheus http serve failed: %s", err.Error()) + if !errors.Is(err, http.ErrServerClosed) { + logrus.Fatalf("prometheus http serve failed: %s", err.Error()) + } } }() diff --git a/reaper.go b/reaper.go index a271bbd..17445e6 100644 --- a/reaper.go +++ b/reaper.go @@ -16,6 +16,11 @@ func forkExec() { logrus.Fatalf("Failed to get current working directory: %s", err.Error()) return } + exe, err := os.Executable() + if err != nil { + logrus.Fatalf("Failed to get executable %s", err.Error()) + return + } pattrs := &syscall.ProcAttr{ Dir: pwd, @@ -28,7 +33,7 @@ func forkExec() { } args := make([]string, 0, len(os.Args)+1) // disable reaping for supercronic, avoid no sense warning - args = append(args, os.Args[0], "-no-reap") + args = append(args, exe, "-no-reap") args = append(args, os.Args[1:]...) pid, err := syscall.ForkExec(args[0], args, pattrs)