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

459 lines
11 KiB
Go

package builds
import (
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"syscall"
"time"
"github.com/dokku/dokku/plugins/common"
"github.com/ryanuber/columnize"
)
// listView holds a Build plus its computed display status, used by
// builds:list / builds:info JSON output.
type listView struct {
Build
DisplayStatus BuildStatus `json:"display_status"`
Duration string `json:"duration"`
LogPath string `json:"log_path"`
}
func toListView(b Build) listView {
return listView{
Build: b,
DisplayStatus: b.DisplayStatus(),
Duration: b.Duration().String(),
LogPath: b.LogPath(),
}
}
// CommandList lists running builds across apps (no app arg) or running + recent
// records for one app.
func CommandList(appName string, format string, kindFilter string, statusFilter string) error {
if format == "" {
format = "stdout"
}
if format != "stdout" && format != "json" {
return fmt.Errorf("Invalid format specified, supported formats: json, stdout")
}
if kindFilter != "" {
k := BuildKind(kindFilter)
if !k.Valid() {
return fmt.Errorf("Invalid --kind value %q (allowed: build, deploy)", kindFilter)
}
}
if statusFilter != "" {
if !validStatusFilter(BuildStatus(statusFilter)) {
return fmt.Errorf("Invalid --status value %q (allowed: running, succeeded, failed, canceled, abandoned)", statusFilter)
}
}
if appName == "" {
return commandListAllRunning(format, kindFilter, statusFilter)
}
if err := common.VerifyAppName(appName); err != nil {
return err
}
all, err := FetchBuilds(appName)
if err != nil {
return err
}
retention := ResolveRetention(appName)
views := make([]listView, 0, len(all))
for _, b := range all {
v := toListView(b)
if !matchesKind(b, kindFilter) {
continue
}
if !matchesStatus(v, statusFilter) {
continue
}
views = append(views, v)
}
sort.SliceStable(views, func(i, j int) bool {
// Live-running first, then by start time descending.
iLive := views[i].Build.Status == BuildStatusRunning && views[i].DisplayStatus == BuildStatusRunning
jLive := views[j].Build.Status == BuildStatusRunning && views[j].DisplayStatus == BuildStatusRunning
if iLive != jLive {
return iLive
}
return views[i].StartedAt.After(views[j].StartedAt)
})
if statusFilter == "" && kindFilter == "" && len(views) > retention {
live := 0
for _, v := range views {
if v.DisplayStatus == BuildStatusRunning {
live++
}
}
cap := live + retention
if cap < len(views) {
views = views[:cap]
}
}
if format == "json" {
body, err := json.Marshal(views)
if err != nil {
return err
}
common.Log(string(body))
return nil
}
if len(views) == 0 {
fmt.Println("No builds recorded for this app")
return nil
}
rows := []string{"Build ID | Kind | Status | PID | Source | Started | Duration"}
for _, v := range views {
rows = append(rows, fmt.Sprintf("%s | %s | %s | %d | %s | %s | %s",
v.ID,
v.Kind,
v.DisplayStatus,
v.PID,
v.Source,
v.StartedAt.Format(time.RFC3339),
v.Duration,
))
}
fmt.Println(columnize.SimpleFormat(rows))
return nil
}
func commandListAllRunning(format, kindFilter, statusFilter string) error {
apps, err := common.DokkuApps()
if err != nil && !errors.Is(err, common.NoAppsExist) {
return err
}
views := []listView{}
for _, appName := range apps {
running, err := FetchBuilds(appName)
if err != nil {
common.LogWarn(fmt.Sprintf("Could not read builds for %s: %s", appName, err))
continue
}
for _, b := range running {
if b.Status != BuildStatusRunning {
continue
}
v := toListView(b)
if !matchesKind(b, kindFilter) {
continue
}
if !matchesStatus(v, statusFilter) {
continue
}
views = append(views, v)
}
}
sort.SliceStable(views, func(i, j int) bool {
return views[i].StartedAt.After(views[j].StartedAt)
})
if format == "json" {
body, err := json.Marshal(views)
if err != nil {
return err
}
common.Log(string(body))
return nil
}
if len(views) == 0 {
fmt.Println("No builds currently running")
return nil
}
rows := []string{"App | Build ID | Kind | PID | Source | Started"}
for _, v := range views {
rows = append(rows, fmt.Sprintf("%s | %s | %s | %d | %s | %s",
v.App,
v.ID,
v.Kind,
v.PID,
v.Source,
v.StartedAt.Format(time.RFC3339),
))
}
fmt.Println(columnize.SimpleFormat(rows))
return nil
}
func matchesKind(b Build, filter string) bool {
if filter == "" {
return true
}
return string(b.Kind) == filter
}
func matchesStatus(v listView, filter string) bool {
if filter == "" {
return true
}
return string(v.DisplayStatus) == filter
}
func validStatusFilter(s BuildStatus) bool {
switch s {
case BuildStatusRunning, BuildStatusSucceeded, BuildStatusFailed, BuildStatusCanceled, BuildStatusAbandoned:
return true
}
return false
}
// CommandInfo prints details for a single build record.
func CommandInfo(appName, buildID, format string) error {
if format == "" {
format = "stdout"
}
if format != "stdout" && format != "json" {
return fmt.Errorf("Invalid format specified, supported formats: json, stdout")
}
if err := common.VerifyAppName(appName); err != nil {
return err
}
if buildID == "" {
return errors.New("Please specify a build id")
}
b, err := ReadBuild(appName, buildID)
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("No build record found for %s/%s", appName, buildID)
}
return err
}
v := toListView(b)
if format == "json" {
body, err := json.Marshal(v)
if err != nil {
return err
}
common.Log(string(body))
return nil
}
rows := []string{
fmt.Sprintf("Build ID:|%s", v.ID),
fmt.Sprintf("App:|%s", v.App),
fmt.Sprintf("Kind:|%s", v.Kind),
fmt.Sprintf("Status:|%s", v.DisplayStatus),
fmt.Sprintf("PID:|%d", v.PID),
fmt.Sprintf("Source:|%s", v.Source),
fmt.Sprintf("Started:|%s", v.StartedAt.Format(time.RFC3339)),
}
if v.FinishedAt != nil {
rows = append(rows, fmt.Sprintf("Finished:|%s", v.FinishedAt.Format(time.RFC3339)))
}
rows = append(rows, fmt.Sprintf("Duration:|%s", v.Duration))
if v.ExitCode != nil {
rows = append(rows, fmt.Sprintf("Exit Code:|%d", *v.ExitCode))
}
rows = append(rows, fmt.Sprintf("Log:|%s", v.LogPath))
fmt.Println(columnize.Format(rows, &columnize.Config{Delim: "|"}))
return nil
}
// CommandCancel cancels the in-flight build for an app.
func CommandCancel(appName string) error {
if err := common.VerifyAppName(appName); err != nil {
return err
}
lockPath := deployLockPath(appName)
body, err := os.ReadFile(lockPath)
if err != nil {
if os.IsNotExist(err) {
common.LogInfo1("App not currently deploying")
return nil
}
return err
}
buildID := strings.TrimSpace(string(body))
if buildID == "" {
common.LogInfo1("No matching app deploy found")
return nil
}
b, err := ReadBuild(appName, buildID)
if err != nil {
if os.IsNotExist(err) {
common.LogInfo1(fmt.Sprintf("No build record for %s, removing stale lock file", buildID))
_ = os.Remove(lockPath)
return nil
}
return err
}
if b.Status != BuildStatusRunning {
common.LogInfo1(fmt.Sprintf("Build %s is no longer running (status=%s); leaving record untouched", buildID, b.Status))
return nil
}
if !CheckPIDAlive(b.PID) {
common.LogInfo1("Build was already terminated, marking failed")
now := time.Now().UTC()
exitCode := -1
b.Status = BuildStatusFailed
b.FinishedAt = &now
b.ExitCode = &exitCode
if err := WriteBuild(b); err != nil {
return err
}
_ = os.Remove(lockPath)
return nil
}
common.LogInfo1("Killing app deploy")
if err := killProcessGroup(b.PID); err != nil {
common.LogWarn(fmt.Sprintf("Failed to signal process group %d: %s", b.PID, err))
}
current, err := ReadBuild(appName, buildID)
if err == nil && current.Status == BuildStatusRunning {
now := time.Now().UTC()
exitCode := -1
current.Status = BuildStatusCanceled
current.FinishedAt = &now
current.ExitCode = &exitCode
if err := WriteBuild(current); err != nil {
return err
}
}
_ = os.Remove(lockPath)
return nil
}
func killProcessGroup(pid int) error {
pgid, err := syscall.Getpgid(pid)
if err != nil {
return err
}
return syscall.Kill(-pgid, syscall.SIGQUIT)
}
func deployLockPath(appName string) string {
return filepath.Join(common.GetAppDataDirectory("apps", appName), ".deploy.lock")
}
// CommandOutput streams the build log for a given build (or the current one).
func CommandOutput(appName, buildID string) error {
if err := common.VerifyAppName(appName); err != nil {
return err
}
if buildID == "" || buildID == "current" {
body, err := os.ReadFile(deployLockPath(appName))
if err != nil {
if os.IsNotExist(err) {
common.LogInfo1("App not currently deploying")
return nil
}
return err
}
buildID = strings.TrimSpace(string(body))
if buildID == "" {
common.LogInfo1("No matching app deploy found")
return nil
}
}
logPath := LogPathFor(appName, buildID)
if _, err := os.Stat(logPath); err != nil {
if os.IsNotExist(err) {
return outputViaJournalctl(buildID)
}
return err
}
b, err := ReadBuild(appName, buildID)
if err != nil && !os.IsNotExist(err) {
return err
}
if err == nil && b.Status == BuildStatusRunning && CheckPIDAlive(b.PID) {
return execStreaming("tail", "-n", "1000", "-f", logPath)
}
return execStreaming("cat", logPath)
}
func outputViaJournalctl(buildID string) error {
if _, err := exec.LookPath("journalctl"); err != nil {
return fmt.Errorf("log file missing and journalctl not available for build %s", buildID)
}
return execStreaming("journalctl", "-n", "1000", "-a", "-o", "cat", fmt.Sprintf("SYSLOG_IDENTIFIER=dokku-%s", buildID))
}
func execStreaming(name string, args ...string) error {
cmd := exec.Command(name, args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
// CommandPrune manually invokes PruneAppBuilds for one or every app.
func CommandPrune(appName string, allApps bool) error {
if allApps {
apps, err := common.DokkuApps()
if err != nil {
if errors.Is(err, common.NoAppsExist) {
common.LogWarn(err.Error())
return nil
}
return err
}
for _, app := range apps {
if err := PruneAppBuilds(app); err != nil {
common.LogWarn(fmt.Sprintf("Prune failed for %s: %s", app, err))
}
}
return nil
}
if err := common.VerifyAppName(appName); err != nil {
return err
}
return PruneAppBuilds(appName)
}
// CommandReport displays a build report for one or more apps.
func CommandReport(appName, format, infoFlag string) error {
if appName == "" {
apps, err := common.DokkuApps()
if err != nil {
if errors.Is(err, common.NoAppsExist) {
common.LogWarn(err.Error())
return nil
}
return err
}
for _, app := range apps {
if err := ReportSingleApp(app, format, infoFlag); err != nil {
return err
}
}
return nil
}
return ReportSingleApp(appName, format, infoFlag)
}