Files
dokku/plugins/builds/builds_test.go
Jose Diaz-Gonzalez 67e4cf04ab feat: migrate builds plugin to go and track per-build records
Adds typed JSON build records under data/builds/<app>/<build-id>.{json,log} keyed on a stable base36 ULID-style DOKKU_BUILD_ID generated for every deploy. The new commands surface that history (builds:list, builds:info, builds:prune) and an operator-configurable retention via builds:set retention. The existing builds:cancel and builds:output now key on the build-id (with safe handling for already-finalized and abandoned records), and the per-build log file replaces journalctl as the durable source of truth for builds:output.
2026-04-30 01:18:03 -04:00

612 lines
16 KiB
Go

package builds
import (
"encoding/json"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
)
func setupTestRoot(t *testing.T) string {
t.Helper()
tmpDir, err := os.MkdirTemp("", "builds-test")
if err != nil {
t.Fatalf("mkdirtemp: %v", err)
}
t.Setenv("DOKKU_LIB_ROOT", tmpDir)
t.Setenv("PLUGIN_PATH", filepath.Join(tmpDir, "plugins"))
t.Setenv("PLUGIN_ENABLED_PATH", filepath.Join(tmpDir, "plugins", "enabled"))
t.Cleanup(func() { os.RemoveAll(tmpDir) })
return tmpDir
}
func TestBuildKindValid(t *testing.T) {
cases := map[BuildKind]bool{
BuildKindBuild: true,
BuildKindDeploy: true,
BuildKind(""): false,
BuildKind("nope"): false,
}
for k, want := range cases {
if got := k.Valid(); got != want {
t.Errorf("BuildKind(%q).Valid() = %v, want %v", string(k), got, want)
}
}
}
func TestBuildStatusValid(t *testing.T) {
cases := map[BuildStatus]bool{
BuildStatusRunning: true,
BuildStatusSucceeded: true,
BuildStatusFailed: true,
BuildStatusCanceled: true,
BuildStatusAbandoned: false, // intentionally not persistable
BuildStatus(""): false,
BuildStatus("nope"): false,
}
for s, want := range cases {
if got := s.Valid(); got != want {
t.Errorf("BuildStatus(%q).Valid() = %v, want %v", string(s), got, want)
}
}
}
func TestBuildStatusIsTerminal(t *testing.T) {
cases := map[BuildStatus]bool{
BuildStatusRunning: false,
BuildStatusSucceeded: true,
BuildStatusFailed: true,
BuildStatusCanceled: true,
BuildStatusAbandoned: false,
}
for s, want := range cases {
if got := s.IsTerminal(); got != want {
t.Errorf("BuildStatus(%q).IsTerminal() = %v, want %v", string(s), got, want)
}
}
}
func TestBuildSourceValid(t *testing.T) {
for _, src := range allBuildSources {
if !src.Valid() {
t.Errorf("expected %q to be valid", string(src))
}
}
for _, bad := range []BuildSource{"", "garbage", "git:", "ps:something"} {
if bad.Valid() {
t.Errorf("expected %q to be invalid", string(bad))
}
}
}
func TestBuildSourceDefaultKind(t *testing.T) {
build := []BuildSource{
BuildSourceGitHook, BuildSourcePsRebuild, BuildSourceGitSync,
BuildSourceGitFromArchive, BuildSourceGitFromImage, BuildSourceGitLoadImage,
}
deploy := []BuildSource{
BuildSourcePsRestart, BuildSourcePsStart, BuildSourceDeploy,
BuildSourceConfigRedeploy, BuildSourceUnknown,
}
for _, s := range build {
if got := s.DefaultKind(); got != BuildKindBuild {
t.Errorf("%q.DefaultKind() = %v, want build", string(s), got)
}
}
for _, s := range deploy {
if got := s.DefaultKind(); got != BuildKindDeploy {
t.Errorf("%q.DefaultKind() = %v, want deploy", string(s), got)
}
}
}
func TestGenerateBuildIDUniquenessAndShape(t *testing.T) {
const samples = 200
seen := make(map[string]struct{}, samples)
for i := 0; i < samples; i++ {
id := GenerateBuildID()
if len(id) != 14 {
t.Fatalf("expected len 14, got %d for %q", len(id), id)
}
for _, r := range id {
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'z')) {
t.Fatalf("non-base36 char %q in %q", r, id)
}
}
if _, dup := seen[id]; dup {
t.Fatalf("duplicate id %q in %d samples", id, samples)
}
seen[id] = struct{}{}
}
}
func TestWriteAndReadBuildRoundTrip(t *testing.T) {
setupTestRoot(t)
now := time.Now().UTC().Truncate(time.Second)
exit := 0
finished := now.Add(time.Minute)
want := Build{
ID: GenerateBuildID(),
App: "myapp",
Kind: BuildKindBuild,
PID: 12345,
StartedAt: now,
FinishedAt: &finished,
Status: BuildStatusSucceeded,
Source: BuildSourceGitHook,
ExitCode: &exit,
}
if err := WriteBuild(want); err != nil {
t.Fatalf("WriteBuild: %v", err)
}
got, err := ReadBuild(want.App, want.ID)
if err != nil {
t.Fatalf("ReadBuild: %v", err)
}
if got.ID != want.ID || got.Status != want.Status || got.Kind != want.Kind || got.Source != want.Source {
t.Errorf("round-trip mismatch: got %+v want %+v", got, want)
}
if got.ExitCode == nil || *got.ExitCode != 0 {
t.Errorf("exit code mismatch: got %v want 0", got.ExitCode)
}
if got.FinishedAt == nil || !got.FinishedAt.Equal(finished) {
t.Errorf("finished_at mismatch: got %v want %v", got.FinishedAt, finished)
}
}
func TestFetchBuildsSortsNewestFirst(t *testing.T) {
setupTestRoot(t)
base := time.Now().UTC().Truncate(time.Second)
for i, offset := range []time.Duration{2 * time.Hour, 1 * time.Hour, 0} {
b := Build{
ID: "build-" + strconv.Itoa(i),
App: "myapp",
Kind: BuildKindBuild,
PID: 100 + i,
StartedAt: base.Add(-offset),
Status: BuildStatusSucceeded,
Source: BuildSourceGitHook,
}
if err := WriteBuild(b); err != nil {
t.Fatalf("write %d: %v", i, err)
}
}
builds, err := FetchBuilds("myapp")
if err != nil {
t.Fatalf("fetch: %v", err)
}
if len(builds) != 3 {
t.Fatalf("got %d builds, want 3", len(builds))
}
for i := 0; i < len(builds)-1; i++ {
if !builds[i].StartedAt.After(builds[i+1].StartedAt) {
t.Errorf("not sorted descending at %d: %v vs %v", i, builds[i].StartedAt, builds[i+1].StartedAt)
}
}
}
func TestFetchBuildsMissingDir(t *testing.T) {
setupTestRoot(t)
builds, err := FetchBuilds("never-deployed")
if err != nil {
t.Fatalf("expected nil err, got %v", err)
}
if len(builds) != 0 {
t.Errorf("expected empty slice, got %d entries", len(builds))
}
}
func TestCheckPIDAlive(t *testing.T) {
if CheckPIDAlive(os.Getpid()) != true {
t.Error("expected current pid to be alive")
}
if CheckPIDAlive(0) {
t.Error("expected pid 0 to be dead")
}
if CheckPIDAlive(-1) {
t.Error("expected pid -1 to be dead")
}
// PID 1 is init/launchd; it always exists. We don't have permission to
// signal it from a non-root user, but CheckPIDAlive should treat EPERM
// as alive.
if !CheckPIDAlive(1) {
t.Error("expected pid 1 (init) to be reported alive")
}
}
func TestReapAbandonedBuilds(t *testing.T) {
setupTestRoot(t)
app := "reapapp"
// 1: alive (this test process), should NOT be reaped.
alive := Build{
ID: "alive",
App: app,
Kind: BuildKindBuild,
PID: os.Getpid(),
StartedAt: time.Now().UTC(),
Status: BuildStatusRunning,
Source: BuildSourceGitHook,
}
// 2: dead pid, status=running, should be reaped.
dead := Build{
ID: "dead",
App: app,
Kind: BuildKindBuild,
PID: 1, // signal-eperm but we treat as alive; use a clearly-dead one
StartedAt: time.Now().UTC(),
Status: BuildStatusRunning,
Source: BuildSourceGitHook,
}
// pick a pid that shouldn't exist
dead.PID = pickDeadPID(t)
// 3: terminal, should not be touched.
finished := time.Now().UTC()
exit := 0
terminal := Build{
ID: "terminal",
App: app,
Kind: BuildKindBuild,
PID: 9999,
StartedAt: time.Now().UTC().Add(-time.Hour),
FinishedAt: &finished,
Status: BuildStatusSucceeded,
Source: BuildSourceGitHook,
ExitCode: &exit,
}
for _, b := range []Build{alive, dead, terminal} {
if err := WriteBuild(b); err != nil {
t.Fatalf("write %s: %v", b.ID, err)
}
}
reaped, err := ReapAbandonedBuilds(app)
if err != nil {
t.Fatalf("reap: %v", err)
}
if reaped != 1 {
t.Errorf("expected 1 reap, got %d", reaped)
}
got, err := ReadBuild(app, "dead")
if err != nil {
t.Fatalf("read dead: %v", err)
}
if got.Status != BuildStatusFailed {
t.Errorf("dead.Status = %v, want failed", got.Status)
}
if got.ExitCode == nil || *got.ExitCode != -1 {
t.Errorf("dead.ExitCode = %v, want -1", got.ExitCode)
}
stillAlive, err := ReadBuild(app, "alive")
if err != nil {
t.Fatalf("read alive: %v", err)
}
if stillAlive.Status != BuildStatusRunning {
t.Errorf("alive.Status = %v, want running", stillAlive.Status)
}
stillTerminal, err := ReadBuild(app, "terminal")
if err != nil {
t.Fatalf("read terminal: %v", err)
}
if stillTerminal.Status != BuildStatusSucceeded {
t.Errorf("terminal.Status = %v, want succeeded", stillTerminal.Status)
}
}
func pickDeadPID(t *testing.T) int {
t.Helper()
for candidate := 99999; candidate < 200000; candidate++ {
if !CheckPIDAlive(candidate) {
return candidate
}
}
t.Fatal("could not find a dead pid candidate")
return 0
}
func TestPruneAppBuildsRespectsRetentionAndProtectsLive(t *testing.T) {
setupTestRoot(t)
app := "pruneapp"
now := time.Now().UTC()
// 1 live in-flight build (status=running, alive PID).
live := Build{
ID: "live",
App: app,
Kind: BuildKindBuild,
PID: os.Getpid(),
StartedAt: now,
Status: BuildStatusRunning,
Source: BuildSourceGitHook,
}
if err := WriteBuild(live); err != nil {
t.Fatalf("write live: %v", err)
}
// Force retention down to 2 via property.
if err := os.MkdirAll(filepath.Join(os.Getenv("DOKKU_LIB_ROOT"), "config", "builds", app), 0755); err != nil {
t.Fatalf("setup property dir: %v", err)
}
if err := os.WriteFile(filepath.Join(os.Getenv("DOKKU_LIB_ROOT"), "config", "builds", app, "retention"), []byte("2"), 0644); err != nil {
t.Fatalf("write retention property: %v", err)
}
// 5 finalized builds with descending start times.
exit := 0
for i := 0; i < 5; i++ {
finished := now.Add(-time.Duration(i+1) * time.Minute)
started := finished.Add(-time.Second)
b := Build{
ID: "old-" + strconv.Itoa(i),
App: app,
Kind: BuildKindBuild,
PID: 9000 + i,
StartedAt: started,
FinishedAt: &finished,
Status: BuildStatusSucceeded,
Source: BuildSourceGitHook,
ExitCode: &exit,
}
if err := WriteBuild(b); err != nil {
t.Fatalf("write old-%d: %v", i, err)
}
// Touch a log file so the prune deletes that too.
_ = os.WriteFile(b.LogPath(), []byte("log"), 0644)
}
if err := PruneAppBuilds(app); err != nil {
t.Fatalf("prune: %v", err)
}
remaining, err := FetchBuilds(app)
if err != nil {
t.Fatalf("fetch after prune: %v", err)
}
// live + 2 retained = 3
if len(remaining) != 3 {
t.Fatalf("expected 3 records after prune, got %d (%v)", len(remaining), remainingIDs(remaining))
}
// live record must survive
foundLive := false
for _, r := range remaining {
if r.ID == "live" {
foundLive = true
if r.Status != BuildStatusRunning {
t.Errorf("live.Status changed to %v", r.Status)
}
}
}
if !foundLive {
t.Errorf("live record was pruned (remaining=%v)", remainingIDs(remaining))
}
// the two newest old-* records (old-0 and old-1) should remain
expectedKept := map[string]bool{"old-0": true, "old-1": true}
for _, r := range remaining {
if r.ID == "live" {
continue
}
if !expectedKept[r.ID] {
t.Errorf("unexpected surviving record %s", r.ID)
}
}
// log files for pruned old-* should be gone; for kept ones, still present.
for i := 2; i < 5; i++ {
path := filepath.Join(AppDataDir(app), "old-"+strconv.Itoa(i)+".log")
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Errorf("expected log %s removed, got err=%v", path, err)
}
}
}
func remainingIDs(builds []Build) []string {
ids := make([]string, 0, len(builds))
for _, b := range builds {
ids = append(ids, b.ID)
}
return ids
}
func TestResolveRetentionCascade(t *testing.T) {
tmp := setupTestRoot(t)
app := "retapp"
// no overrides: default
if got := ResolveRetention(app); got != DefaultRetention {
t.Errorf("no overrides: got %d want %d", got, DefaultRetention)
}
// global override
globalDir := filepath.Join(tmp, "config", "builds", "--global")
if err := os.MkdirAll(globalDir, 0755); err != nil {
t.Fatalf("mkdir global: %v", err)
}
if err := os.WriteFile(filepath.Join(globalDir, "retention"), []byte("7"), 0644); err != nil {
t.Fatalf("write global retention: %v", err)
}
if got := ResolveRetention(app); got != 7 {
t.Errorf("global override: got %d want 7", got)
}
// per-app override beats global
appDir := filepath.Join(tmp, "config", "builds", app)
if err := os.MkdirAll(appDir, 0755); err != nil {
t.Fatalf("mkdir app: %v", err)
}
if err := os.WriteFile(filepath.Join(appDir, "retention"), []byte("3"), 0644); err != nil {
t.Fatalf("write app retention: %v", err)
}
if got := ResolveRetention(app); got != 3 {
t.Errorf("per-app override: got %d want 3", got)
}
// invalid app value falls back to global
if err := os.WriteFile(filepath.Join(appDir, "retention"), []byte("not-a-number"), 0644); err != nil {
t.Fatalf("write invalid: %v", err)
}
if got := ResolveRetention(app); got != 7 {
t.Errorf("invalid app value: got %d want 7 (global)", got)
}
// negative value also falls through
if err := os.WriteFile(filepath.Join(appDir, "retention"), []byte("0"), 0644); err != nil {
t.Fatalf("write zero: %v", err)
}
if got := ResolveRetention(app); got != 7 {
t.Errorf("zero app value: got %d want 7 (global)", got)
}
}
func TestDisplayStatusComputesAbandoned(t *testing.T) {
setupTestRoot(t)
abandoned := Build{
ID: "x",
App: "myapp",
Kind: BuildKindBuild,
PID: pickDeadPID(t),
StartedAt: time.Now().UTC(),
Status: BuildStatusRunning,
Source: BuildSourceGitHook,
}
if got := abandoned.DisplayStatus(); got != BuildStatusAbandoned {
t.Errorf("DisplayStatus on dead-PID running record = %v, want abandoned", got)
}
live := abandoned
live.PID = os.Getpid()
if got := live.DisplayStatus(); got != BuildStatusRunning {
t.Errorf("DisplayStatus on alive-PID running record = %v, want running", got)
}
exit := 0
finished := time.Now().UTC()
terminal := Build{
ID: "y",
App: "myapp",
Kind: BuildKindBuild,
PID: os.Getpid(),
StartedAt: time.Now().UTC(),
FinishedAt: &finished,
Status: BuildStatusSucceeded,
Source: BuildSourceGitHook,
ExitCode: &exit,
}
if got := terminal.DisplayStatus(); got != BuildStatusSucceeded {
t.Errorf("terminal DisplayStatus = %v, want succeeded", got)
}
}
func TestRecordStartFinalizeIdempotency(t *testing.T) {
setupTestRoot(t)
app := "idem"
id := GenerateBuildID()
if err := TriggerBuildsRecordStart(app, id, strconv.Itoa(os.Getpid()), string(BuildSourceGitHook)); err != nil {
t.Fatalf("record-start: %v", err)
}
// First finalize: succeeded
if err := TriggerBuildsRecordFinalize(app, id, "0"); err != nil {
t.Fatalf("first finalize: %v", err)
}
first, err := ReadBuild(app, id)
if err != nil {
t.Fatalf("read after first finalize: %v", err)
}
if first.Status != BuildStatusSucceeded {
t.Errorf("first finalize Status = %v, want succeeded", first.Status)
}
// Manually mark canceled, then call finalize with non-zero exit; the
// canceled status should win because cancel finalized it first.
canceled := first
canceled.Status = BuildStatusCanceled
if err := WriteBuild(canceled); err != nil {
t.Fatalf("write canceled: %v", err)
}
if err := TriggerBuildsRecordFinalize(app, id, "1"); err != nil {
t.Fatalf("second finalize: %v", err)
}
final, err := ReadBuild(app, id)
if err != nil {
t.Fatalf("read after second finalize: %v", err)
}
if final.Status != BuildStatusCanceled {
t.Errorf("idempotent finalize Status = %v, want canceled", final.Status)
}
}
func TestRecordStartUnknownSourceCoercedToUnknown(t *testing.T) {
setupTestRoot(t)
app := "src"
id := GenerateBuildID()
if err := TriggerBuildsRecordStart(app, id, strconv.Itoa(os.Getpid()), "made-up-source"); err != nil {
t.Fatalf("record-start: %v", err)
}
got, err := ReadBuild(app, id)
if err != nil {
t.Fatalf("read: %v", err)
}
if got.Source != BuildSourceUnknown {
t.Errorf("Source = %v, want unknown", got.Source)
}
if got.Kind != BuildKindDeploy {
t.Errorf("Kind = %v, want deploy (DefaultKind for unknown)", got.Kind)
}
}
func TestBuildJSONShape(t *testing.T) {
setupTestRoot(t)
now := time.Now().UTC().Truncate(time.Second)
b := Build{
ID: "abc",
App: "j",
Kind: BuildKindDeploy,
PID: 100,
StartedAt: now,
Status: BuildStatusRunning,
Source: BuildSourcePsRestart,
}
if err := WriteBuild(b); err != nil {
t.Fatalf("write: %v", err)
}
raw, err := os.ReadFile(RecordPath(b.App, b.ID))
if err != nil {
t.Fatalf("read raw: %v", err)
}
var parsed map[string]any
if err := json.Unmarshal(raw, &parsed); err != nil {
t.Fatalf("unmarshal: %v", err)
}
for _, key := range []string{"id", "app", "kind", "pid", "started_at", "status", "source"} {
if _, ok := parsed[key]; !ok {
t.Errorf("expected key %q in JSON, got %s", key, string(raw))
}
}
for _, omittable := range []string{"finished_at", "exit_code"} {
if _, ok := parsed[omittable]; ok {
t.Errorf("expected key %q to be omitted while running, got %s", omittable, string(raw))
}
}
if !strings.Contains(string(raw), `"kind": "deploy"`) {
t.Errorf("kind not serialized as enum string: %s", string(raw))
}
}