Files
dokku/plugins/builds/builds.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

447 lines
12 KiB
Go

package builds
import (
"crypto/rand"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"syscall"
"time"
"github.com/dokku/dokku/plugins/common"
"github.com/multiformats/go-base36"
)
const (
DefaultRetention = 20
MinimumRetention = 1
)
var (
DefaultProperties = map[string]string{
"retention": strconv.Itoa(DefaultRetention),
}
GlobalProperties = map[string]bool{
"retention": true,
}
)
// BuildKind represents whether a record is a full build or a deploy-only run.
type BuildKind string
const (
BuildKindBuild BuildKind = "build"
BuildKindDeploy BuildKind = "deploy"
)
// Valid reports whether the kind is one of the recognized values.
func (k BuildKind) Valid() bool {
return k == BuildKindBuild || k == BuildKindDeploy
}
// BuildStatus is the on-disk lifecycle status of a build record.
// BuildStatusAbandoned is intentionally NOT considered Valid - it is a
// display-only value computed at read time and never persisted.
type BuildStatus string
const (
BuildStatusRunning BuildStatus = "running"
BuildStatusSucceeded BuildStatus = "succeeded"
BuildStatusFailed BuildStatus = "failed"
BuildStatusCanceled BuildStatus = "canceled"
BuildStatusAbandoned BuildStatus = "abandoned"
)
// Valid reports whether the status is a persistable value.
func (s BuildStatus) Valid() bool {
switch s {
case BuildStatusRunning, BuildStatusSucceeded, BuildStatusFailed, BuildStatusCanceled:
return true
}
return false
}
// IsTerminal reports whether the status represents a finalized build.
func (s BuildStatus) IsTerminal() bool {
switch s {
case BuildStatusSucceeded, BuildStatusFailed, BuildStatusCanceled:
return true
}
return false
}
// BuildSource identifies the user-typed command (or internal trigger) that
// originated a build.
type BuildSource string
const (
BuildSourceGitHook BuildSource = "git-hook"
BuildSourcePsRebuild BuildSource = "ps:rebuild"
BuildSourcePsRestart BuildSource = "ps:restart"
BuildSourcePsStart BuildSource = "ps:start"
BuildSourceDeploy BuildSource = "deploy"
BuildSourceConfigRedeploy BuildSource = "config-redeploy"
BuildSourceGitSync BuildSource = "git:sync"
BuildSourceGitFromArchive BuildSource = "git:from-archive"
BuildSourceGitFromImage BuildSource = "git:from-image"
BuildSourceGitLoadImage BuildSource = "git:load-image"
BuildSourceUnknown BuildSource = "unknown"
)
var allBuildSources = []BuildSource{
BuildSourceGitHook,
BuildSourcePsRebuild,
BuildSourcePsRestart,
BuildSourcePsStart,
BuildSourceDeploy,
BuildSourceConfigRedeploy,
BuildSourceGitSync,
BuildSourceGitFromArchive,
BuildSourceGitFromImage,
BuildSourceGitLoadImage,
BuildSourceUnknown,
}
// Valid reports whether the source is one of the recognized values.
func (s BuildSource) Valid() bool {
for _, candidate := range allBuildSources {
if s == candidate {
return true
}
}
return false
}
// DefaultKind maps a source to the kind of work it performs. Sources that
// produce a new image map to BuildKindBuild; sources that re-deploy an existing
// image map to BuildKindDeploy.
func (s BuildSource) DefaultKind() BuildKind {
switch s {
case BuildSourceGitHook,
BuildSourcePsRebuild,
BuildSourceGitSync,
BuildSourceGitFromArchive,
BuildSourceGitFromImage,
BuildSourceGitLoadImage:
return BuildKindBuild
}
return BuildKindDeploy
}
// Build is the persisted record for a single build/deploy.
type Build struct {
ID string `json:"id"`
App string `json:"app"`
Kind BuildKind `json:"kind"`
PID int `json:"pid"`
StartedAt time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
Status BuildStatus `json:"status"`
Source BuildSource `json:"source"`
ExitCode *int `json:"exit_code,omitempty"`
}
// DisplayStatus returns the status the operator should see, computing
// BuildStatusAbandoned for running records whose PID is no longer alive.
func (b Build) DisplayStatus() BuildStatus {
if b.Status == BuildStatusRunning && !CheckPIDAlive(b.PID) {
return BuildStatusAbandoned
}
return b.Status
}
// Duration returns the elapsed time between start and finish (or now, for
// in-flight builds).
func (b Build) Duration() time.Duration {
end := time.Now().UTC()
if b.FinishedAt != nil {
end = *b.FinishedAt
}
if end.Before(b.StartedAt) {
return 0
}
return end.Sub(b.StartedAt).Round(time.Second)
}
// LogPath returns the on-disk path of the per-build log file.
func (b Build) LogPath() string {
return filepath.Join(common.GetAppDataDirectory("builds", b.App), b.ID+".log")
}
// GenerateBuildID produces a sortable base36 ULID-style id.
//
// Format: <8 base36 chars of ms timestamp><6 base36 chars of randomness>.
func GenerateBuildID() string {
const tsLen = 8
const randLen = 6
ms := uint64(time.Now().UTC().UnixNano() / int64(time.Millisecond))
tsBytes := make([]byte, 8)
for i := 7; i >= 0; i-- {
tsBytes[i] = byte(ms & 0xff)
ms >>= 8
}
tsEncoded := base36.EncodeToStringLc(tsBytes)
if len(tsEncoded) > tsLen {
tsEncoded = tsEncoded[len(tsEncoded)-tsLen:]
}
for len(tsEncoded) < tsLen {
tsEncoded = "0" + tsEncoded
}
randBytes := make([]byte, 6)
if _, err := rand.Read(randBytes); err != nil {
// fall back to time-derived noise; uniqueness is degraded but never zero
now := time.Now().UTC().UnixNano()
for i := 0; i < len(randBytes); i++ {
randBytes[i] = byte(now >> (8 * i))
}
}
randEncoded := base36.EncodeToStringLc(randBytes)
if len(randEncoded) > randLen {
randEncoded = randEncoded[len(randEncoded)-randLen:]
}
for len(randEncoded) < randLen {
randEncoded = "0" + randEncoded
}
return tsEncoded + randEncoded
}
// AppDataDir returns the directory that stores build records and logs for app.
func AppDataDir(appName string) string {
return common.GetAppDataDirectory("builds", appName)
}
// RecordPath returns the on-disk path to the JSON record for a given build.
func RecordPath(appName, buildID string) string {
return filepath.Join(AppDataDir(appName), buildID+".json")
}
// LogPathFor returns the on-disk path to the log file for a given build.
func LogPathFor(appName, buildID string) string {
return filepath.Join(AppDataDir(appName), buildID+".log")
}
// WriteBuild writes a Build record to disk. It creates the data dir as needed.
func WriteBuild(b Build) error {
if err := os.MkdirAll(AppDataDir(b.App), 0755); err != nil {
return fmt.Errorf("create builds data dir: %w", err)
}
body, err := json.MarshalIndent(b, "", " ")
if err != nil {
return fmt.Errorf("marshal build record: %w", err)
}
tmp := RecordPath(b.App, b.ID) + ".tmp"
if err := os.WriteFile(tmp, body, 0644); err != nil {
return fmt.Errorf("write temp build record: %w", err)
}
if err := os.Rename(tmp, RecordPath(b.App, b.ID)); err != nil {
return fmt.Errorf("rename build record into place: %w", err)
}
return nil
}
// ReadBuild loads the Build record from disk. Returns os.ErrNotExist when the
// record does not exist.
func ReadBuild(appName, buildID string) (Build, error) {
var b Build
body, err := os.ReadFile(RecordPath(appName, buildID))
if err != nil {
return b, err
}
if err := json.Unmarshal(body, &b); err != nil {
return b, fmt.Errorf("parse build record %s: %w", buildID, err)
}
return b, nil
}
// FetchBuilds returns every recorded build for an app, sorted newest-first by
// StartedAt. It is read-only; callers that want to reap abandoned records
// should call ReapAbandonedBuilds explicitly via PruneAppBuilds or the
// builds:prune subcommand.
func FetchBuilds(appName string) ([]Build, error) {
dir := AppDataDir(appName)
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("list builds dir for %s: %w", appName, err)
}
builds := make([]Build, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
if !strings.HasSuffix(name, ".json") {
continue
}
buildID := strings.TrimSuffix(name, ".json")
b, err := ReadBuild(appName, buildID)
if err != nil {
common.LogWarn(fmt.Sprintf("Skipping unreadable build record %s/%s: %s", appName, name, err))
continue
}
builds = append(builds, b)
}
sort.Slice(builds, func(i, j int) bool {
return builds[i].StartedAt.After(builds[j].StartedAt)
})
return builds, nil
}
// FetchRunningBuilds returns the in-flight (display-status running) builds for
// an app. Records whose on-disk status is running but PID is dead are excluded.
func FetchRunningBuilds(appName string) ([]Build, error) {
all, err := FetchBuilds(appName)
if err != nil {
return nil, err
}
running := make([]Build, 0, len(all))
for _, b := range all {
if b.Status == BuildStatusRunning && CheckPIDAlive(b.PID) {
running = append(running, b)
}
}
return running, nil
}
// CheckPIDAlive reports whether a process with the given pid currently exists.
// On unix, kill(pid, 0) returns nil if the process exists (or ESRCH if it does
// not). It treats invalid pids (<= 0) as dead.
func CheckPIDAlive(pid int) bool {
if pid <= 0 {
return false
}
if err := syscall.Kill(pid, 0); err != nil {
if err == syscall.ESRCH {
return false
}
// EPERM means the process exists but we don't have permission to
// signal it - still alive from the operator's perspective.
if err == syscall.EPERM {
return true
}
return false
}
return true
}
// ResolveRetention returns the retention count for an app, cascading from
// per-app override → global override → DefaultRetention. Invalid (non-int or
// non-positive) property values fall through to the next level with a warning.
func ResolveRetention(appName string) int {
if appName != "" && appName != "--global" {
if n, ok := parseRetention(common.PropertyGet("builds", appName, "retention"), appName); ok {
return n
}
}
if n, ok := parseRetention(common.PropertyGet("builds", "--global", "retention"), "--global"); ok {
return n
}
return DefaultRetention
}
func parseRetention(raw string, scope string) (int, bool) {
if raw == "" {
return 0, false
}
n, err := strconv.Atoi(raw)
if err != nil {
common.LogWarn(fmt.Sprintf("Ignoring non-integer builds retention %q for %s", raw, scope))
return 0, false
}
if n < MinimumRetention {
common.LogWarn(fmt.Sprintf("Ignoring builds retention %d below minimum %d for %s", n, MinimumRetention, scope))
return 0, false
}
return n, true
}
// ReapAbandonedBuilds finalizes any record whose on-disk status is running but
// whose PID is no longer alive. Records are written with status=failed,
// exit_code=-1, finished_at=now. Returns the number of records reaped.
func ReapAbandonedBuilds(appName string) (int, error) {
builds, err := FetchBuilds(appName)
if err != nil {
return 0, err
}
now := time.Now().UTC()
exitCode := -1
reaped := 0
for _, b := range builds {
if b.Status != BuildStatusRunning {
continue
}
if CheckPIDAlive(b.PID) {
continue
}
b.Status = BuildStatusFailed
b.FinishedAt = &now
b.ExitCode = &exitCode
if err := WriteBuild(b); err != nil {
common.LogWarn(fmt.Sprintf("Could not reap abandoned build %s/%s: %s", appName, b.ID, err))
continue
}
reaped++
}
return reaped, nil
}
// PruneAppBuilds reaps abandoned records, then prunes finalized records beyond
// the retention cap. Live in-flight builds (status=running with alive PID) are
// always preserved.
func PruneAppBuilds(appName string) error {
if _, err := ReapAbandonedBuilds(appName); err != nil {
return err
}
builds, err := FetchBuilds(appName)
if err != nil {
return err
}
retention := ResolveRetention(appName)
finalized := make([]Build, 0, len(builds))
for _, b := range builds {
if b.Status == BuildStatusRunning && CheckPIDAlive(b.PID) {
continue
}
finalized = append(finalized, b)
}
if len(finalized) <= retention {
return nil
}
for _, b := range finalized[retention:] {
removeBuildFiles(appName, b.ID)
}
return nil
}
func removeBuildFiles(appName, buildID string) {
if err := os.Remove(RecordPath(appName, buildID)); err != nil && !os.IsNotExist(err) {
common.LogWarn(fmt.Sprintf("Could not remove build record %s/%s: %s", appName, buildID, err))
}
if err := os.Remove(LogPathFor(appName, buildID)); err != nil && !os.IsNotExist(err) {
common.LogWarn(fmt.Sprintf("Could not remove build log %s/%s.log: %s", appName, buildID, err))
}
}