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.
459 lines
11 KiB
Go
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)
|
|
}
|