mirror of
https://github.com/dokku/dokku.git
synced 2026-05-18 05:05:46 +02:00
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.
447 lines
12 KiB
Go
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))
|
|
}
|
|
}
|