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
44 changes: 29 additions & 15 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
@@ -1,27 +1,41 @@
# This workflow will build a golang project
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go

name: Go

on:
push:
branches: [ "main" ]
branches: [main]
pull_request:
branches: [ "main" ]
branches: [main]

permissions:
contents: read

jobs:
build:
test:
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- run: go vet ./...
- run: go test -race -count=1 -cover ./...
- run: python3 -m unittest discover -s scripts -p 'test_*.py'
- name: PID 1 and capability integration tests
if: runner.os == 'Linux'
run: |
docker pull alpine:3.21
GONNER_DOCKER_TESTS=1 go test -count=1 -timeout=10m ./internal/integration
vulnerabilities:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Go
uses: actions/setup-go@v5
- uses: actions/setup-go@v5
with:
go-version-file: go.mod

- name: Build
run: go build -v ./...

- name: Test
run: go test -v ./...
- run: go install golang.org/x/vuln/cmd/govulncheck@v1.8.0
- run: govulncheck ./...
- name: Build and scan all release targets
run: bash scripts/check-artifacts.sh
51 changes: 34 additions & 17 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,31 @@ concurrency:
cancel-in-progress: false

jobs:
verify:
if: github.event.pull_request.merged == true
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
permissions:
contents: read
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.merge_commit_sha }}
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
- run: go vet ./...
- run: go test -race -count=1 -cover ./...
- run: python3 -m unittest discover -s scripts -p 'test_*.py'
- name: PID 1 and capability integration tests
if: runner.os == 'Linux'
run: |
docker pull alpine:3.21
GONNER_DOCKER_TESTS=1 go test -count=1 -timeout=10m ./internal/integration
release:
needs: verify
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest

Expand All @@ -40,17 +64,9 @@ jobs:
- name: Determine release bump
id: bump
shell: bash
run: |
labels='${{ toJson(github.event.pull_request.labels.*.name) }}'
bump=patch

if echo "$labels" | grep -q '"release:major"'; then
bump=major
elif echo "$labels" | grep -q '"release:minor"'; then
bump=minor
fi

echo "level=$bump" >> "$GITHUB_OUTPUT"
env:
PR_LABELS_JSON: ${{ toJson(github.event.pull_request.labels.*.name) }}
run: python3 scripts/release_bump.py >> "$GITHUB_OUTPUT"

- name: Compute next tag
id: version
Expand Down Expand Up @@ -91,17 +107,18 @@ jobs:
- name: Create local release tag
run: git tag "${{ steps.version.outputs.tag }}" "${{ github.event.pull_request.merge_commit_sha }}"

- name: Run tests
run: |
export PATH="$(go env GOPATH)/bin:$PATH"
task test

- name: Build release artifacts
run: |
export PATH="$(go env GOPATH)/bin:$PATH"
task release
(cd bin && shasum -a 256 gonner-* > checksums.txt)

- name: Check vulnerabilities before publishing
run: |
go install golang.org/x/vuln/cmd/govulncheck@v1.8.0
govulncheck ./...
bash scripts/check-artifacts.sh bin

- name: Push release tag
run: git push origin "${{ steps.version.outputs.tag }}"

Expand All @@ -113,4 +130,4 @@ jobs:
bin/* \
--target "${{ github.event.pull_request.merge_commit_sha }}" \
--title "${{ steps.version.outputs.tag }}" \
--generate-notes
--generate-notes
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,6 @@ coverage.txt

# IDEs
.vscode/
.zed/
.zed/
# Python release helper bytecode
__pycache__/
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,35 @@

All notable changes to this project are documented in this file. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## v0.1.0

### Security

- Resolve all 25 findings from the codebase audit, with permanent regression coverage and a resolution matrix in `docs/audits/2026-09-25-codebase-audit.md`.
- Validate log and PID paths through retained directory descriptors; reject unsafe ownership, writable ancestors, leaf symlinks, hardlinks, special files, and unsafe ACLs before workloads start.
- Resolve numeric users through account lookup, require a group for unknown UIDs, clear supplementary groups, and check Linux capabilities including `CAP_KILL` for cross-UID supervision.
- Raise the Go baseline to 1.26.7 and scan source plus all release binaries with pinned govulncheck v1.8.0 before publication.
- Parse release labels as JSON environment data instead of interpolating them into shell commands.

### Fixed

- Give every managed child exactly one wait owner; coordinate Linux PID 1 orphan reaping with the shared execution service used by main commands, precommands, and command conditions.
- Treat macOS groups containing only exited processes as stopped while preserving signal permission errors for live processes.
- Preserve oversized and unterminated output, drain pipes after child exit, share log sinks across instances, prevent backup collisions, and keep console output flowing after file errors.
- Propagate unavailable dependencies without hangs and allow any successfully started instance to satisfy the initial dependency gate.
- Report permanent failures with exit 1, cancel all work for critical failures including required precommands, and allow unrelated work to continue after noncritical failures.
- Correct restart counts, backoff stability tracking, and process status while waiting to restart.
- Validate TLS certificates before binding or starting workloads, propagate unexpected health-server failures, and protect uptime reads against races.
- Include pending and skipped critical processes in readiness and status, normalize bare ports in TCP conditions, and honor an explicit `gonner status --port 8089` over environment defaults.

### Changed

- **Migration required:** custom conditions implement `Evaluate(context.Context) (bool, error)`; command conditions inherit process credentials, environment, and working directory and terminate their group on cancellation or timeout.
- **Migration required:** strict path and privilege validation can reject previously accepted configurations; sequential forward dependencies are rejected.
- Oversized console lines are split into prefixed 64 KiB chunks. Raw files remain byte-exact, and legacy rotated files are preserved rather than pruned.
- Release publication requires native Linux and macOS verification, Docker PID 1 and privilege-boundary tests, and vulnerability scans. Binaries cover Linux/macOS on amd64 and arm64.
- See `docs/operations.md#upgrade-from-the-audited-baseline` before upgrading.

## v0.0.3

### Added
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

A lightweight, PID-1-aware process manager for containers. Single static Go binary, no runtime dependencies. Designed to be a Docker `ENTRYPOINT` that orchestrates multiple long-running services, but works equally well for local development.

[![Go Version](https://img.shields.io/badge/go-1.25%2B-blue)](go.mod)
[![Go Version](https://img.shields.io/badge/go-1.26.7%2B-blue)](go.mod)
[![License](https://img.shields.io/badge/license-MIT-green)](LICENSE)

---
Expand Down Expand Up @@ -44,7 +44,7 @@ A lightweight, PID-1-aware process manager for containers. Single static Go bina
Download a prebuilt binary from [Releases](https://github.com/michaelishri/gonner/releases), or build from source:

```bash
# requires Go 1.25+
# requires Go 1.26.7+
go install github.com/michaelishri/gonner/cmd/gonner@latest
```

Expand Down
2 changes: 1 addition & 1 deletion Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ tasks:
test:
desc: Run tests with race detection and coverage
cmds:
- go test ./... -v -race -cover
- go test ./... -v -race -count=1 -cover

lint:
desc: Run golangci-lint
Expand Down
48 changes: 40 additions & 8 deletions cmd/gonner/cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,18 @@ package cmd

import (
"context"
"errors"
"fmt"
"os"
"path/filepath"

"github.com/spf13/cobra"

"github.com/michaelishri/gonner/internal/config"
"github.com/michaelishri/gonner/internal/health"
"github.com/michaelishri/gonner/internal/logging"
"github.com/michaelishri/gonner/internal/runner"
"github.com/michaelishri/gonner/internal/safefile"
)

var (
Expand Down Expand Up @@ -68,6 +71,8 @@ func runRun(_ *cobra.Command, _ []string) error {
go sigHandler.Start()
defer sigHandler.Stop()

var healthErr error
var healthDone chan struct{}
// Start health endpoint if configured
if port > 0 {
opts := health.Options{
Expand All @@ -87,21 +92,48 @@ func runRun(_ *cobra.Command, _ []string) error {
if err := healthSrv.Start(ctx); err != nil {
return fmt.Errorf("failed to start health endpoint: %w", err)
}
healthDone = make(chan struct{})
go func() {
defer close(healthDone)
healthErr = <-healthSrv.Errors()
if healthErr != nil {
cancel()
}
}()
defer func() { cancel(); <-healthSrv.Done(); <-healthDone }()

}

logging.Gonner("Gonner starting (PID %d)", os.Getpid())

// Write PID file if configured.
// PID files use the same descriptor-relative safety policy as logs.
if cfg.PIDFile != "" {
if err := os.WriteFile(cfg.PIDFile, []byte(fmt.Sprintf("%d\n", os.Getpid())), 0o644); err != nil {
logging.Gonner("warning: failed to write pidFile %s: %v", cfg.PIDFile, err)
} else {
defer os.Remove(cfg.PIDFile)
dir, err := safefile.OpenDirectory(filepath.Dir(cfg.PIDFile))
if err != nil {
return fmt.Errorf("pidFile: %w", err)
}
defer dir.Close()
name := filepath.Base(cfg.PIDFile)
f, err := dir.OpenRegular(name, os.O_WRONLY|os.O_CREATE, 0644)
if err != nil {
return fmt.Errorf("pidFile: %w", err)
}
writeErr := f.Truncate(0)
if writeErr == nil {
_, writeErr = fmt.Fprintf(f, "%d\n", os.Getpid())
}
closeErr := f.Close()
if err := errors.Join(writeErr, closeErr); err != nil {
return fmt.Errorf("pidFile: %w", err)
}
defer dir.Remove(name)
}

// Run all processes (blocks until done)
if err := mgr.Run(ctx); err != nil {
err = mgr.Run(ctx)
cancel()
if healthDone != nil {
<-healthDone
}
if err = errors.Join(err, healthErr); err != nil {
return fmt.Errorf("manager exited with error: %w", err)
}

Expand Down
28 changes: 27 additions & 1 deletion cmd/gonner/cmd/shutdown_linux_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ func TestRunShutdown(t *testing.T) {
t.Fatal("supervisor did not finish shutdown")
}
elapsed := time.Since(start)
if waitErr != nil {
if (waitErr != nil) != tc.critical {
t.Errorf("supervisor exit: %v\n%s", waitErr, &output)
}
stopped, err := os.ReadFile(filepath.Join(dir, "stopped"))
Expand Down Expand Up @@ -302,3 +302,29 @@ func TestShutdownHelper(t *testing.T) {
}
os.Exit(0)
}

func TestInvalidTLSPreventsWorkloadStartup(t *testing.T) {
dir := t.TempDir()
marker := filepath.Join(dir, "should-not-run")
cfg := map[string]any{"health": map[string]any{"port": 8089, "tls": map[string]string{"certFile": "/gonner-missing-cert", "keyFile": "/gonner-missing-key"}}, "run": []map[string]any{{"name": "worker", "command": "touch " + shellQuote(marker)}}}
data, err := json.Marshal(cfg)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "gonner.json"), data, 0600); err != nil {
t.Fatal(err)
}
executable, err := os.Executable()
if err != nil {
t.Fatal(err)
}
command := exec.Command(executable, "-test.run=^TestShutdownHelper$", "--", "gonner")
command.Env = append(os.Environ(), "GONNER_SHUTDOWN_HELPER=1", "GONNER_SHUTDOWN_DIR="+dir, "GONNER_HEALTH_PORT=0")
output, err := command.CombinedOutput()
if err == nil || !strings.Contains(string(output), "loading health TLS certificate") {
t.Fatalf("exit=%v output=%s", err, output)
}
if _, err := os.Stat(marker); !os.IsNotExist(err) {
t.Fatal("workload ran after TLS startup failure")
}
}
13 changes: 7 additions & 6 deletions cmd/gonner/cmd/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,8 @@ type statusAPIResponse struct {
} `json:"processes"`
}

func runStatus(_ *cobra.Command, _ []string) error {
port := resolveStatusPort()
func runStatus(cmd *cobra.Command, _ []string) error {
port := resolveStatusPort(cmd.Flags().Changed("port"))
scheme := "http"
if statusTLS {
scheme = "https"
Expand Down Expand Up @@ -119,13 +119,14 @@ func runStatus(_ *cobra.Command, _ []string) error {

// resolveStatusPort determines the status endpoint port.
// CLI flag takes priority, then GONNER_HEALTH_PORT env var, then default.
func resolveStatusPort() int {
func resolveStatusPort(explicit bool) int {
if explicit {
return statusPort
}
if envPort := os.Getenv("GONNER_HEALTH_PORT"); envPort != "" {
var port int
if _, err := fmt.Sscanf(envPort, "%d", &port); err == nil && port > 0 {
if statusPort == health.DefaultHealthPort {
return port
}
return port
}
}
return statusPort
Expand Down
26 changes: 26 additions & 0 deletions cmd/gonner/cmd/status_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package cmd

import (
"testing"

"github.com/spf13/cobra"

"github.com/michaelishri/gonner/internal/health"
)

func TestExplicitDefaultStatusPortOverridesEnvironment(t *testing.T) {
old := statusPort
defer func() { statusPort = old }()
t.Setenv("GONNER_HEALTH_PORT", "9999")
command := &cobra.Command{}
command.Flags().IntVar(&statusPort, "port", health.DefaultHealthPort, "")
if got := resolveStatusPort(command.Flags().Changed("port")); got != 9999 {
t.Fatalf("env port=%d", got)
}
if err := command.Flags().Set("port", "8089"); err != nil {
t.Fatal(err)
}
if got := resolveStatusPort(command.Flags().Changed("port")); got != 8089 {
t.Fatalf("explicit port=%d", got)
}
}
Loading
Loading