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
31 changes: 31 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -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
48 changes: 26 additions & 22 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ on:
branches: [master]
pull_request:
branches: [master]
workflow_dispatch:

jobs:
build:
Expand All @@ -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
48 changes: 48 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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="<unset>"

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" ]
8 changes: 6 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,21 @@ 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/)
go vet $$(go list ./... | grep -v /vendor/)

.PHONY: integration
integration: VERSION=v1337
integration: build
integration: build docker-build
bats integration

.PHONY: test
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down
4 changes: 2 additions & 2 deletions cronexpr/cronexpr/go.mod
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -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
Expand Down
52 changes: 28 additions & 24 deletions integration/test.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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
Expand All @@ -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" {
Expand Down Expand Up @@ -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'
}
7 changes: 4 additions & 3 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down Expand Up @@ -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())
}
}()
}
Expand Down
17 changes: 13 additions & 4 deletions prometheus_metrics/prommetrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package prometheus_metrics

import (
"context"
"errors"
"fmt"
"net"
"net/http"
Expand Down Expand Up @@ -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,
)
Expand Down Expand Up @@ -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(`<html>
_, err := w.Write([]byte(`<html>
<head><title>Supercronic</title></head>
<body>
<h1>Supercronic</h1>
<p><a href='/metrics'>Metrics</a></p>
</body>
</html>`))
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 {
Expand All @@ -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())
}
}
}()

Expand Down
7 changes: 6 additions & 1 deletion reaper.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
Expand Down
Loading