R-621: a held app's logs show the log the hold kept
GetLogs (logs page, /api/stacks/<n>/logs, the operator's remote diagnostics) returned "" for a held app — no containers — while captureHoldLogs had kept the app's own log under hold-logs/<ts>/compose-logs.txt. It now serves the newest non-empty kept log, under a header naming the file, when the live log is empty; a running app's own output always wins. GetLogs gains a test seam (logsComposeFn) so the test never reaches docker. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -291,6 +291,8 @@ type Manager struct {
|
||||
// --- guarded update (slice 4, update.go) ---
|
||||
updateGuards UpdateGuards // init-only, SetUpdateGuards; nil ⇒ every update is REFUSED
|
||||
updateComposeFn func(dir string, env []string, args ...string) (string, error)
|
||||
// logsComposeFn is GetLogs' compose seam (tests only; nil → the real compose). R-621.
|
||||
logsComposeFn func(dir string, args ...string) (string, error)
|
||||
// composeDownFn is RecoverInterruptedInstalls' seam (R-681): nil → the real `compose down`. Tests set a
|
||||
// fake so they never reach real Docker (R-650).
|
||||
composeDownFn func(dir string) error
|
||||
@@ -1448,11 +1450,26 @@ func (m *Manager) GetLogs(name string, lines int) (string, error) {
|
||||
m.logger.Printf("[INFO] [stacks] Fetching logs for stack %s (tail=%d)", name, lines)
|
||||
|
||||
dir := filepath.Dir(stack.ComposePath)
|
||||
output, err := m.composeExec(dir, "logs", "--tail", fmt.Sprintf("%d", lines), "--no-color")
|
||||
run := m.composeExec
|
||||
if m.logsComposeFn != nil {
|
||||
run = m.logsComposeFn
|
||||
}
|
||||
output, err := run(dir, "logs", "--tail", fmt.Sprintf("%d", lines), "--no-color")
|
||||
if err != nil {
|
||||
m.logger.Printf("[WARN] [stacks] Failed to fetch logs for %s: %v", name, err)
|
||||
return "", fmt.Errorf("getting logs for %s: %w", name, err)
|
||||
}
|
||||
// R-621: a HELD app has no containers, so its live log is empty — but the hold kept the app's own
|
||||
// log before the `down` (captureHoldLogs). Serve the newest kept log then, under a header naming
|
||||
// the file, so the logs page, the API and the operator's remote diagnostics all show WHY the
|
||||
// update failed instead of nothing. Only when the live log is empty: a running app's own output
|
||||
// always wins. Pinned by TestR621_GetLogsFallsBackToTheHoldLog.
|
||||
if strings.TrimSpace(output) == "" {
|
||||
if kept, ts, ok := latestHoldLog(dir); ok {
|
||||
m.logger.Printf("[INFO] [stacks] Logs for %s: no live output — serving the log kept at the update hold (%s, %d bytes)", name, ts, len(kept))
|
||||
return "--- hold-logs/" + ts + "/compose-logs.txt ---\n" + kept, nil
|
||||
}
|
||||
}
|
||||
|
||||
if len(output) == 0 {
|
||||
m.logger.Printf("[DEBUG] Logs result for %s: 0 bytes returned (empty)", name)
|
||||
@@ -1462,6 +1479,29 @@ func (m *Manager) GetLogs(name string, lines int) (string, error) {
|
||||
return output, nil
|
||||
}
|
||||
|
||||
// latestHoldLog returns the newest non-empty hold-logs/<ts>/compose-logs.txt under a stack dir. The
|
||||
// directory names are UTC timestamps (20060102T150405Z), so the lexical order is the time order.
|
||||
func latestHoldLog(dir string) (content, ts string, ok bool) {
|
||||
entries, err := os.ReadDir(filepath.Join(dir, "hold-logs"))
|
||||
if err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Sort(sort.Reverse(sort.StringSlice(names)))
|
||||
for _, n := range names {
|
||||
b, rerr := os.ReadFile(filepath.Join(dir, "hold-logs", n, "compose-logs.txt"))
|
||||
if rerr == nil && strings.TrimSpace(string(b)) != "" {
|
||||
return string(b), n, true
|
||||
}
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// --- Env and compose helpers ---
|
||||
|
||||
// stackEnv builds the full OS env slice for a stack, merging app.yaml values.
|
||||
|
||||
Reference in New Issue
Block a user