v0.294.0: off-site clean-up guard follows the policy's own constants (R-867); no image clean-up while compose pulls (R-863); stderr tail (R-864); move-aside destination logged (R-869)
gates / gates (push) Successful in 28s
gates / gates (push) Successful in 28s
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:
@@ -15,6 +15,7 @@ import (
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/crypto"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||
"gopkg.in/yaml.v3"
|
||||
@@ -837,7 +838,8 @@ func (m *Manager) PersistUnitRedeployConfig(name string, env map[string]string)
|
||||
// so USERDATA_PATH must be injected here too (mirrors stackEnv), else the FIRST deploy resolves
|
||||
// ${USERDATA_PATH} to "" and binds a bogus root-owned dir at the container root.
|
||||
func (m *Manager) composeExecWithEnv(dir string, env map[string]string, args ...string) (string, error) {
|
||||
if m.composeExecFn != nil { // test seam (v0.280.0): a deploy test never reaches Docker
|
||||
defer dockerexec.BeginImageWork(args)() // R-863 (held around the seam too, so a test sees the real rule)
|
||||
if m.composeExecFn != nil { // test seam (v0.280.0): a deploy test never reaches Docker
|
||||
return m.composeExecFn(dir, env, args...)
|
||||
}
|
||||
cmdEnv := os.Environ()
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
|
||||
)
|
||||
@@ -35,6 +38,10 @@ var imageDocker = func(args ...string) (string, error) {
|
||||
|
||||
var imageRetentionMu sync.Mutex
|
||||
|
||||
// errImageRetentionBusy: a pass did not run because an image may be in use by work in flight (an update, or
|
||||
// any compose command that pulls — R-863). The caller tries again later; nothing was judged.
|
||||
var errImageRetentionBusy = errors.New("image work in flight")
|
||||
|
||||
type localImage struct {
|
||||
ID, Repo, Tag, Digest, Size string
|
||||
}
|
||||
@@ -194,8 +201,17 @@ func (m *Manager) deleteUnkeptImages(why string, repos map[string]bool, except s
|
||||
m.mu.RUnlock()
|
||||
if busy != "" {
|
||||
m.logger.Printf("[INFO] [stacks] image retention (%s): skipped — %s is updating (its undo may need an image nothing else names)", why, busy)
|
||||
return nil, nil
|
||||
return nil, errImageRetentionBusy
|
||||
}
|
||||
// R-863: an install, a restore or an undo inside `compose up` may have pulled an image (by digest, so
|
||||
// untagged) that no container names YET. No pass while any image-pulling compose command runs; one that
|
||||
// starts now waits for this pass (seconds).
|
||||
endCleanup, ok := dockerexec.TryImageCleanup()
|
||||
if !ok {
|
||||
m.logger.Printf("[INFO] [stacks] image retention (%s): skipped — an app install, update, restore or undo is pulling images now (R-863); tried again later", why)
|
||||
return nil, errImageRetentionBusy
|
||||
}
|
||||
defer endCleanup()
|
||||
imgs, err := listLocalImages()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -293,7 +309,7 @@ func (m *Manager) RetainImagesAfterUpdate(name string, previous map[string]Insta
|
||||
m.logger.Printf("[INFO] [stacks] image retention after the update of %s: the app is gone — nothing to do here", name)
|
||||
return
|
||||
}
|
||||
if _, err := m.deleteUnkeptImages("update of "+name, appImageRepos(dir, st.AppConfig), ""); err != nil {
|
||||
if _, err := m.deleteUnkeptImages("update of "+name, appImageRepos(dir, st.AppConfig), ""); err != nil && !errors.Is(err, errImageRetentionBusy) {
|
||||
m.logger.Printf("[WARN] [stacks] image retention after the update of %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
@@ -317,7 +333,7 @@ func (m *Manager) RetainImagesAfterRemove(name string, repos map[string]bool) {
|
||||
if len(repos) == 0 {
|
||||
return
|
||||
}
|
||||
if _, err := m.deleteUnkeptImages("remove of "+name, repos, name); err != nil {
|
||||
if _, err := m.deleteUnkeptImages("remove of "+name, repos, name); err != nil && !errors.Is(err, errImageRetentionBusy) {
|
||||
m.logger.Printf("[WARN] [stacks] image retention after the remove of %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
@@ -349,28 +365,49 @@ func (m *Manager) imageRetentionMarker() string {
|
||||
|
||||
// RunImageRetentionOnce is the one-time clean-up at the first start of this release: the same rule, applied to every
|
||||
// app image the catalog names (so the images of apps removed before this release go too). Logged; a marker file
|
||||
// keeps it to once. Returns what it deleted.
|
||||
func (m *Manager) RunImageRetentionOnce() []string {
|
||||
// keeps it to once. Returns what it deleted, and done=false when it must be tried again (no catalog yet, or image
|
||||
// work in flight — R-863: the marker is written ONLY after a pass that ran).
|
||||
func (m *Manager) RunImageRetentionOnce() (deleted []string, done bool) {
|
||||
if _, err := os.Stat(m.imageRetentionMarker()); err == nil {
|
||||
return nil
|
||||
return nil, true
|
||||
}
|
||||
repos := m.catalogImageRepos()
|
||||
if len(repos) == 0 {
|
||||
m.logger.Printf("[WARN] [stacks] image retention (one-time): no catalog read — skipped, tried again at the next start")
|
||||
return nil
|
||||
m.logger.Printf("[WARN] [stacks] image retention (one-time): no catalog read — skipped, tried again later")
|
||||
return nil, false
|
||||
}
|
||||
before, _ := imageDocker("system", "df", "--format", "{{.Type}} {{.Size}} {{.Reclaimable}}")
|
||||
deleted, err := m.deleteUnkeptImages("one-time clean-up", repos, "")
|
||||
if errors.Is(err, errImageRetentionBusy) {
|
||||
return nil, false // logged by the pass; tried again later
|
||||
}
|
||||
if err != nil {
|
||||
m.logger.Printf("[WARN] [stacks] image retention (one-time): %v — tried again at the next start", err)
|
||||
return nil
|
||||
m.logger.Printf("[WARN] [stacks] image retention (one-time): %v — tried again later", err)
|
||||
return nil, false
|
||||
}
|
||||
after, _ := imageDocker("system", "df", "--format", "{{.Type}} {{.Size}} {{.Reclaimable}}")
|
||||
m.logger.Printf("[INFO] [stacks] image retention (one-time): deleted %d image(s). docker disk before: %s | after: %s",
|
||||
len(deleted), strings.Join(strings.Fields(firstLine(before)), " "), strings.Join(strings.Fields(firstLine(after)), " "))
|
||||
_ = os.MkdirAll(filepath.Dir(m.imageRetentionMarker()), 0o755)
|
||||
_ = os.WriteFile(m.imageRetentionMarker(), []byte(fmt.Sprintf("deleted %d\n%s\n", len(deleted), strings.Join(deleted, "\n"))), 0o644)
|
||||
return deleted
|
||||
return deleted, true
|
||||
}
|
||||
|
||||
// RunImageRetentionOnceUntilDone runs the one-time clean-up, and again every `every` until it has run (R-863:
|
||||
// a pass that met image work in flight, or found no catalog yet, is retried — no longer only at the next start),
|
||||
// at most `tries` times.
|
||||
func (m *Manager) RunImageRetentionOnceUntilDone(ctx context.Context, every time.Duration, tries int) {
|
||||
for i := 0; i < tries; i++ {
|
||||
if _, done := m.RunImageRetentionOnce(); done {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(every):
|
||||
}
|
||||
}
|
||||
m.logger.Printf("[WARN] [stacks] image retention (one-time): not run after %d tries — tried again at the next start", tries)
|
||||
}
|
||||
|
||||
func sortedKeys(m map[string]bool) []string {
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
|
||||
)
|
||||
|
||||
// R-863 (v0.294.0) — THE NIGHT'S EXACT SHAPE (2026-10-04, a fresh box): the household's first install is inside
|
||||
// `compose up`; compose has pulled `mariadb@sha256:…` (stored untagged, `mariadb:<none>`) and not yet created the
|
||||
// container; the controller's one-time image clean-up fires (3 minutes after its first start). v0.293.0 deleted
|
||||
// the image — no container, installed app or undo named it — and `compose up` failed one second later.
|
||||
// COMPANION RED-PROOF: drop the dockerexec.TryImageCleanup check in deleteUnkeptImages → the clean-up deletes
|
||||
// sha256:MDB and the install fails ("the install failed").
|
||||
func TestR863_OneTimeCleanupDuringFirstInstallKeepsThePulledImage(t *testing.T) {
|
||||
m := gateManager(t, "display_name: Book\ndeploy_fields:\n - env_var: DOMAIN\n type: domain\n - env_var: SUBDOMAIN\n type: subdomain\n default: gapp\n")
|
||||
m.cfg.Paths.DataDir = filepath.Join(t.TempDir(), "data")
|
||||
cat := filepath.Join(m.cfg.Paths.DataDir, "catalog-cache", "templates", "bookstack")
|
||||
must(t, os.MkdirAll(cat, 0o755))
|
||||
must(t, os.WriteFile(filepath.Join(cat, "docker-compose.yml"), []byte("services:\n db:\n image: mariadb:11.4@sha256:mdb\n"), 0o644))
|
||||
|
||||
f := &fakeImages{containers: map[string]string{}}
|
||||
var fmu sync.Mutex
|
||||
prev := imageDocker
|
||||
imageDocker = func(args ...string) (string, error) { fmu.Lock(); defer fmu.Unlock(); return f.run(args...) }
|
||||
t.Cleanup(func() { imageDocker = prev })
|
||||
|
||||
var cleanupDone bool
|
||||
m.composeExecFn = func(_ string, _ map[string]string, args ...string) (string, error) {
|
||||
if len(args) == 0 || args[0] != "up" {
|
||||
return "", nil
|
||||
}
|
||||
// compose pulls by digest: the image exists, untagged, named by no container yet
|
||||
fmu.Lock()
|
||||
f.imgs = append(f.imgs, localImage{ID: "sha256:MDB", Repo: "mariadb", Tag: "<none>", Digest: "sha256:mdb", Size: "334MB"})
|
||||
fmu.Unlock()
|
||||
// the one-time clean-up fires NOW, from its own goroutine, as at 19:45:04
|
||||
res := make(chan bool, 1)
|
||||
go func() { _, done := m.RunImageRetentionOnce(); res <- done }()
|
||||
select {
|
||||
case cleanupDone = <-res:
|
||||
case <-time.After(10 * time.Second):
|
||||
return "", fmt.Errorf("the clean-up blocked")
|
||||
}
|
||||
// compose creates the container from the pulled image — if it is still there
|
||||
fmu.Lock()
|
||||
defer fmu.Unlock()
|
||||
for _, im := range f.imgs {
|
||||
if im.ID == "sha256:MDB" {
|
||||
f.containers["c-db"] = "sha256:MDB"
|
||||
return "", nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("exit code 1\nstderr: Error response from daemon: No such image: mariadb@sha256:mdb")
|
||||
}
|
||||
done := make(chan bool, 1)
|
||||
m.SetDeployDoneHook(func(_ string, ok bool, _ string) { done <- ok })
|
||||
if _, err := m.DeployStack(DeployRequest{StackName: "gapp"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case ok := <-done:
|
||||
if !ok {
|
||||
t.Fatalf("the install failed: the clean-up deleted the image compose had just pulled (rmi %v)", f.rmi)
|
||||
}
|
||||
case <-time.After(20 * time.Second):
|
||||
t.Fatal("the deploy never ended")
|
||||
}
|
||||
if len(f.rmi) != 0 {
|
||||
t.Fatalf("the clean-up deleted during the install: %v", f.rmi)
|
||||
}
|
||||
if cleanupDone {
|
||||
t.Fatal("the clean-up reported done although it did not run — its marker would end it for good")
|
||||
}
|
||||
if _, err := os.Stat(m.imageRetentionMarker()); err == nil {
|
||||
t.Fatal("marker written for a pass that did not run")
|
||||
}
|
||||
// After the install the retried clean-up runs, and the app's image is kept (a container names it).
|
||||
if _, done := m.RunImageRetentionOnce(); !done {
|
||||
t.Fatal("the retried clean-up did not run once nothing was pulling")
|
||||
}
|
||||
if strings.Contains(strings.Join(f.rmi, ","), "sha256:MDB") {
|
||||
t.Fatal("the installed app's database image was deleted")
|
||||
}
|
||||
}
|
||||
|
||||
// Image-pulling verbs hold the lock; others do not (a `ps` or `down` must never hold off a clean-up).
|
||||
func TestR863_OnlyPullingVerbsHoldTheLock(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
args []string
|
||||
want bool
|
||||
}{
|
||||
{[]string{"up", "-d"}, true}, {[]string{"compose", "up", "-d", "--remove-orphans"}, true},
|
||||
{[]string{"pull"}, true}, {[]string{"-p", "x", "create"}, true}, {[]string{"run", "--rm", "x"}, true},
|
||||
{[]string{"ps"}, false}, {[]string{"down"}, false}, {[]string{"stop"}, false}, {[]string{"-p", "up", "down"}, false},
|
||||
} {
|
||||
if got := imagePullingForTest(c.args); got != c.want {
|
||||
t.Errorf("%v: pulling=%v, want %v", c.args, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func imagePullingForTest(args []string) bool { return dockerexec.ImagePulling(args) }
|
||||
@@ -1,6 +1,7 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -214,7 +215,7 @@ func TestImageRetention_OneTimeSweepOnlyCatalogRepos(t *testing.T) {
|
||||
cat := filepath.Join(m.cfg.Paths.DataDir, "catalog-cache", "templates", "gone")
|
||||
must(t, os.MkdirAll(cat, 0o755))
|
||||
must(t, os.WriteFile(filepath.Join(cat, "docker-compose.yml"), []byte("services:\n gone:\n image: acme/gone:6\n"), 0o644))
|
||||
deleted := m.RunImageRetentionOnce()
|
||||
deleted, _ := m.RunImageRetentionOnce()
|
||||
got := strings.Join(f.rmi, ",")
|
||||
if !strings.Contains(got, "sha256:OLD") {
|
||||
t.Fatalf("an earlier-removed app's image was not swept: %v", deleted)
|
||||
@@ -242,8 +243,8 @@ func TestImageRetention_NoPassWhileAnUpdateRuns(t *testing.T) {
|
||||
m.stacks["docs"].Updating = true
|
||||
m.mu.Unlock()
|
||||
st, _ := m.GetStack("web")
|
||||
if _, err := m.deleteUnkeptImages("test", appImageRepos(filepath.Dir(st.ComposePath), st.AppConfig), ""); err != nil {
|
||||
t.Fatal(err)
|
||||
if _, err := m.deleteUnkeptImages("test", appImageRepos(filepath.Dir(st.ComposePath), st.AppConfig), ""); !errors.Is(err, errImageRetentionBusy) {
|
||||
t.Fatalf("err = %v, want errImageRetentionBusy (the caller tries again later)", err)
|
||||
}
|
||||
if len(f.rmi) != 0 {
|
||||
t.Fatalf("a pass ran while docs was updating: %v", f.rmi)
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/appbackup"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
@@ -1446,6 +1447,7 @@ func (m *Manager) composeExec(dir string, args ...string) (string, error) {
|
||||
}
|
||||
|
||||
func (m *Manager) composeExecCustomEnv(dir string, env []string, args ...string) (string, error) {
|
||||
defer dockerexec.BeginImageWork(args)() // R-863: no image clean-up while this may pull
|
||||
var cmd *exec.Cmd
|
||||
|
||||
if m.composeCmd == "docker compose" {
|
||||
@@ -1511,10 +1513,12 @@ func (m *Manager) composeExecCustomEnv(dir string, env []string, args ...string)
|
||||
if stdoutStr := truncateStr(stdout.String(), 500); stdoutStr != "" {
|
||||
m.logger.Printf("[ERROR] [stacks] stdout: %s", stdoutStr)
|
||||
}
|
||||
if stderrStr := truncateStr(stderr.String(), 500); stderrStr != "" {
|
||||
m.logger.Printf("[ERROR] [stacks] stderr: %s", stderrStr)
|
||||
// R-864 (v0.294.0): the TAIL of stderr — compose prints its pull progress first and the reason last, so
|
||||
// the head (v0.293.0 and earlier) logged "Image … Pulling" and cut the error itself.
|
||||
if stderrStr := tailStr(stderr.String(), 500); stderrStr != "" {
|
||||
m.logger.Printf("[ERROR] [stacks] stderr (last part): %s", stderrStr)
|
||||
}
|
||||
return stdout.String(), fmt.Errorf("exit code %d\nstderr: %s", exitCode, truncateStr(stderr.String(), 500))
|
||||
return stdout.String(), fmt.Errorf("exit code %d\nstderr: %s", exitCode, tailStr(stderr.String(), 500))
|
||||
}
|
||||
|
||||
m.logger.Printf("[DEBUG] Command completed: %s %s (took %.1fs)", m.composeCmd, strings.Join(args, " "), time.Since(start).Seconds())
|
||||
@@ -1549,6 +1553,20 @@ func (m *Manager) isDebug() bool {
|
||||
}
|
||||
|
||||
// truncateStr truncates a string to maxLen characters, appending "..." if truncated.
|
||||
// tailStr keeps the LAST maxLen bytes of s (an error is printed last), marked with a leading "...". The cut
|
||||
// moves forward to a UTF-8 boundary so a Hungarian letter is never split. Pinned by TestR864_*.
|
||||
func tailStr(s string, maxLen int) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
i := len(s) - maxLen
|
||||
for i < len(s) && !utf8.RuneStart(s[i]) {
|
||||
i++
|
||||
}
|
||||
return "..." + s[i:]
|
||||
}
|
||||
|
||||
func truncateStr(s string, maxLen int) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) <= maxLen {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package stacks
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// R-864 (v0.294.0): a failed compose command logs and returns the TAIL of stderr. THE NIGHT'S SHAPE (2026-10-04,
|
||||
// BookStack on the fresh Tester 1 box, audits/night-2026-10-04/tester1/t8-*): the stderr opened with the pull
|
||||
// progress below (the first line is verbatim from that night) and the reason came last; v0.293.0 kept the first
|
||||
// 500 bytes and logged only "Image … Pulling" — the reason was lost. The night's last line itself was lost by
|
||||
// exactly this defect; the one here is Docker's message for an image deleted under compose (R-863).
|
||||
// COMPANION RED-PROOF: use truncateStr instead of tailStr for stderr in composeExecCustomEnv → "the reason was cut".
|
||||
func TestR864_FailedComposeLogsTheReasonNotThePullProgress(t *testing.T) {
|
||||
var lines []string
|
||||
lines = append(lines, "Image lscr.io/linuxserver/bookstack:26.09.1@sha256:99cd1f5707c1911afad213adec5c9739763b76f843d1477142231834ecdcb6f7 Pulling")
|
||||
lines = append(lines, "Image mariadb:11.4@sha256:dfff46ef3f9d Pulling")
|
||||
for i := 0; i < 16; i++ {
|
||||
lines = append(lines, " a1b2c3d4e5f6 Pull complete")
|
||||
}
|
||||
lines = append(lines, "Image mariadb:11.4@sha256:dfff46ef3f9d Pulled")
|
||||
reason := "Error response from daemon: No such image: mariadb@sha256:dfff46ef3f9d — árvíztűrő"
|
||||
lines = append(lines, reason)
|
||||
stderr := strings.Join(lines, "\n")
|
||||
if len(stderr) < 700 {
|
||||
t.Fatalf("the fixture must exceed the 500-byte cut (%d)", len(stderr))
|
||||
}
|
||||
m := gateManager(t, "display_name: G\n")
|
||||
dir := t.TempDir() // a stub under os.TempDir(): R-650's sanctioned seam, never this host's Docker
|
||||
errf := filepath.Join(dir, "stderr.txt")
|
||||
must(t, os.WriteFile(errf, []byte(stderr), 0o644))
|
||||
must(t, os.WriteFile(filepath.Join(dir, "docker"), []byte("#!/bin/sh\nwhile IFS= read -r l || [ -n \"$l\" ]; do printf '%s\\n' \"$l\" >&2; done < "+errf+"\nexit 1\n"), 0o755))
|
||||
t.Setenv("PATH", dir)
|
||||
var buf bytes.Buffer
|
||||
m.logger = log.New(&buf, "", 0)
|
||||
_, err := m.composeExecCustomEnv(dir, []string{"PATH=" + dir}, "up", "-d")
|
||||
if err == nil {
|
||||
t.Fatal("no error from a failed compose")
|
||||
}
|
||||
if !strings.Contains(err.Error(), reason) {
|
||||
t.Fatalf("the reason was cut from the returned error: %q", err.Error())
|
||||
}
|
||||
if !strings.Contains(buf.String(), reason) {
|
||||
t.Fatalf("the reason was cut from the log:\n%s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestR864_TailStrKeepsTheEndOnARuneBoundary(t *testing.T) {
|
||||
s := strings.Repeat("x", 10) + "őőő"
|
||||
got := tailStr(s, 5) // the cut falls inside "ő" (2 bytes each)
|
||||
if got != "...őő" {
|
||||
t.Fatalf("tailStr = %q", got)
|
||||
}
|
||||
if tailStr("short", 500) != "short" {
|
||||
t.Fatal("a short string must pass unchanged")
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||
@@ -690,6 +691,7 @@ func (m *Manager) UpdateErrorFor(st Stack, lang string) string {
|
||||
}
|
||||
|
||||
func (m *Manager) updateCompose(dir string, env []string, args ...string) (string, error) {
|
||||
defer dockerexec.BeginImageWork(args)() // R-863
|
||||
if m.updateComposeFn != nil {
|
||||
return m.updateComposeFn(dir, env, args...)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user