v0.253.0 — errors carry the key of the sentence they are (R-557 slice 2 release B)
gates / gates (push) Successful in 24s
gates / gates (push) Successful in 24s
179 Hungarian sentences were built deep inside a package with fmt.Errorf and printed by whoever caught them: too late to translate where they are shown, too early where they are made. Every one now carries its key across that gap. ZERO Hungarian error literals remain. util.MsgError does three things at once, each earned: - Error() is the Hungarian, byte for byte, so every un-converted printer is unchanged; - errors.Is answers for the kind AND for a wrapped cause (KindErrorf dropped the cause); - an error ARGUMENT renders recursively, so "formázás sikertelen: %w" translates whole. A foreign error — restic, docker, ssh, the stdlib — prints verbatim. It is not ours. 76 display sites go through errText, and TestNoErrErrorInPageOutput convicts any that do not. memoryVerdict returns an error rather than a sentence, so the deploy's 409 and the household's language come from one value; UpdateRefusal gained a Cause to carry it. Plurals, one rule, stated once: a key with .one/.other takes its COUNT first. Not a per-call-site flag — the producer somebody forgot would read "3 app is not running". The guard caught a real key collision (alert.deadapp.one) the day the rule landed. TWO DEFECTS FOUND IN MY OWN TOOLING, recorded rather than quietly fixed. The bulk converter silently dropped multi-line concatenations, damaging 7 producers — and the parity gate could not see it, because every surviving fragment WAS a real base literal while the CALL had lost text; two behaviour tests caught it. And the counting script was case-sensitive, so it said "0 left" while five remained. MinAgent: 0.131.0 (unchanged). No hub release needed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
@@ -45,13 +45,13 @@ var subdomainRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`)
|
||||
// validateSubdomain checks that a subdomain is DNS-safe.
|
||||
func validateSubdomain(s string) error {
|
||||
if s == "" {
|
||||
return fmt.Errorf("az aldomain nem lehet üres")
|
||||
return util.MsgError("err.stacks.az_aldomain_nem_lehet_ures")
|
||||
}
|
||||
if len(s) > 63 {
|
||||
return fmt.Errorf("az aldomain legfeljebb 63 karakter lehet")
|
||||
}
|
||||
if !subdomainRe.MatchString(s) {
|
||||
return fmt.Errorf("az aldomain csak kisbetűket, számokat és kötőjelet tartalmazhat, és nem kezdődhet/végződhet kötőjellel")
|
||||
return util.MsgError("err.stacks.az_aldomain_csak_kisbetuket_szamokat_es")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -240,9 +240,9 @@ func (m *Manager) DeployStack(req DeployRequest) (string, error) {
|
||||
// Slice 4: the block moved into memoryVerdict so the guarded update applies the SAME check with
|
||||
// the SAME wording. Behaviour here is unchanged — same inputs, same log line, same refusal text.
|
||||
refusal, deployWarning := m.memoryVerdict(ParseMemoryMB(meta.Resources.MemRequest), ParseMemoryMB(meta.Resources.MemLimit), 0, 0)
|
||||
if refusal != "" {
|
||||
if refusal != nil {
|
||||
clearDeploying()
|
||||
return "", util.KindError(ErrNotEnoughMemory, refusal)
|
||||
return "", refusal
|
||||
}
|
||||
|
||||
// Debug: log received values (redact passwords/secrets)
|
||||
@@ -280,11 +280,11 @@ func (m *Manager) DeployStack(req DeployRequest) (string, error) {
|
||||
}
|
||||
if reservedSubdomains[value] {
|
||||
clearDeploying()
|
||||
return "", fmt.Errorf("a(z) %q aldomain foglalt rendszer számára", value)
|
||||
return "", util.MsgError("err.stacks.a_z_aldomain_foglalt_rendszer_szamara", value)
|
||||
}
|
||||
if m.SubdomainInUse(value, req.StackName) {
|
||||
clearDeploying()
|
||||
return "", fmt.Errorf("a(z) %q aldomain már használatban van egy másik alkalmazásban", value)
|
||||
return "", util.MsgError("err.stacks.a_z_aldomain_mar_hasznalatban_van", value)
|
||||
}
|
||||
|
||||
case "secret":
|
||||
@@ -308,7 +308,7 @@ func (m *Manager) DeployStack(req DeployRequest) (string, error) {
|
||||
value = userVal
|
||||
} else {
|
||||
clearDeploying()
|
||||
return "", util.KindErrorf(ErrRequiredField, "a(z) %q mező kitöltése kötelező — használja a Generálás gombot vagy írjon be egy jelszót", field.Label)
|
||||
return "", util.MsgErrorf(ErrRequiredField, "err.stacks.field_required_password", field.Label)
|
||||
}
|
||||
|
||||
default:
|
||||
@@ -323,7 +323,7 @@ func (m *Manager) DeployStack(req DeployRequest) (string, error) {
|
||||
// Validate required fields
|
||||
if field.Required && value == "" {
|
||||
clearDeploying()
|
||||
return "", util.KindErrorf(ErrRequiredField, "a(z) %q (%s) mező kitöltése kötelező", field.Label, field.EnvVar)
|
||||
return "", util.MsgErrorf(ErrRequiredField, "err.stacks.field_required", field.Label, field.EnvVar)
|
||||
}
|
||||
|
||||
// Validate path fields exist on the host filesystem
|
||||
@@ -1197,9 +1197,14 @@ func randomAlphanumeric(length int) (string, error) {
|
||||
// update replaces the app's own current request, so counting both would refuse an update that fits.
|
||||
// A deploy releases nothing and passes 0, 0.
|
||||
//
|
||||
// Returns the refusal (the deploy's own Hungarian wording, "" = admitted) and the soft overcommit
|
||||
// warning. An unreadable memory reading admits with a WARN, exactly as the deploy always has.
|
||||
func (m *Manager) memoryVerdict(newReqMB, newLimitMB, releasedReqMB, releasedLimitMB int) (refusal, warning string) {
|
||||
// Returns the refusal (nil = admitted) and the soft overcommit warning.
|
||||
//
|
||||
// v0.253.0 (R-557): the refusal is an ERROR rather than a sentence, and it carries BOTH its kind
|
||||
// (ErrNotEnoughMemory, so api.deployStatusFor still answers 409) and its message key (so the
|
||||
// household reads it in its own language). One value where there used to be a sentence plus a
|
||||
// wrapper at each call site. An unreadable memory reading admits with a WARN, exactly as the deploy
|
||||
// always has.
|
||||
func (m *Manager) memoryVerdict(newReqMB, newLimitMB, releasedReqMB, releasedLimitMB int) (refusal error, warning string) {
|
||||
reservedMB := m.cfg.System.ReservedMemoryMB
|
||||
totalMB, usedMB, memErr := system.GetMemoryMB()
|
||||
// F1: the controller container cannot read the guest's RAM cap from /proc (no lxcfs) or its own
|
||||
@@ -1216,7 +1221,7 @@ func (m *Manager) memoryVerdict(newReqMB, newLimitMB, releasedReqMB, releasedLim
|
||||
}
|
||||
if memErr != nil {
|
||||
m.logger.Printf("[WARN] [stacks] Cannot read system memory: %v — skipping memory check", memErr)
|
||||
return "", ""
|
||||
return nil, ""
|
||||
}
|
||||
usedMB -= releasedReqMB
|
||||
if usedMB < 0 {
|
||||
@@ -1229,10 +1234,7 @@ func (m *Manager) memoryVerdict(newReqMB, newLimitMB, releasedReqMB, releasedLim
|
||||
|
||||
// Hard block: committed + new request exceeds usable memory
|
||||
if newReqMB > 0 && usedMB+newReqMB > usableMB {
|
||||
return fmt.Sprintf(
|
||||
"Nincs elég memória az alkalmazás telepítéséhez. "+
|
||||
"Szükséges: %d MB, Elérhető: %d MB "+
|
||||
"(összesen: %d MB, ebből %d MB használt, %d MB rendszer számára fenntartva)",
|
||||
return util.MsgErrorf(ErrNotEnoughMemory, "err.stacks.not_enough_memory",
|
||||
newReqMB,
|
||||
usableMB-usedMB,
|
||||
totalMB,
|
||||
@@ -1245,8 +1247,7 @@ func (m *Manager) memoryVerdict(newReqMB, newLimitMB, releasedReqMB, releasedLim
|
||||
_, currentLimitMB := m.CommittedMemory()
|
||||
currentLimitMB -= releasedLimitMB
|
||||
if newLimitMB > 0 && currentLimitMB+newLimitMB > totalMB {
|
||||
warning = "Az alkalmazások csúcsterhelése meghaladhatja a rendelkezésre álló memóriát. " +
|
||||
"Normál használat mellett ez nem okoz problémát."
|
||||
warning = msgHU("err.stacks.memory_overcommit_warning")
|
||||
}
|
||||
return "", warning
|
||||
return nil, warning
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package stacks
|
||||
|
||||
import "errors"
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
|
||||
)
|
||||
|
||||
// R-553 — the deploy path's refusals carry a KIND, so the API can pick its status code without
|
||||
// reading the customer's Hungarian sentence. The sentences themselves are unchanged (a test pins
|
||||
@@ -19,3 +23,22 @@ var (
|
||||
// ErrNotEnoughMemory — the memory verdict refused the deploy (API: 400).
|
||||
ErrNotEnoughMemory = errors.New("not enough memory")
|
||||
)
|
||||
|
||||
// msgHU renders a bundle message in Hungarian.
|
||||
//
|
||||
// It exists for the few strings this package returns as TEXT rather than as an error — the soft
|
||||
// overcommit warning is the only one today. Those are display strings with no error to carry a key,
|
||||
// so the copy lives in the bundle (a translator sees it, and scripts/i18n_go_parity.py pins it) while
|
||||
// the value handed back is still Hungarian. **The consequence, stated rather than hidden: such a
|
||||
// string renders Hungarian on an English page.** Filed as R-575; the fix is for the caller to carry
|
||||
// the key the way Alert and UpdateRefusal do, not for this helper to guess a language it cannot know.
|
||||
func msgHU(key string, args ...interface{}) string {
|
||||
b, err := i18n.Shared()
|
||||
if err != nil {
|
||||
return key
|
||||
}
|
||||
if len(args) == 0 {
|
||||
return b.Msg(i18n.Default, key)
|
||||
}
|
||||
return b.Msgf(i18n.Default, key, args...)
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package stacks
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
@@ -115,11 +116,11 @@ func (m *Manager) SetAppEmailEnabled(name string, enabled bool) error {
|
||||
stackDir := filepath.Dir(stack.ComposePath)
|
||||
meta := LoadMetadata(stackDir)
|
||||
if !meta.HasSMTPMapping() {
|
||||
return fmt.Errorf("a(z) %q alkalmazás nem támogatja az email-küldést", name)
|
||||
return util.MsgError("err.stacks.a_z_alkalmazas_nem_tamogatja_az", name)
|
||||
}
|
||||
appCfg := LoadAppConfig(stackDir)
|
||||
if appCfg == nil || !appCfg.Deployed {
|
||||
return fmt.Errorf("a(z) %q alkalmazás nincs telepítve", name)
|
||||
return util.MsgError("err.stacks.a_z_alkalmazas_nincs_telepitve", name)
|
||||
}
|
||||
if appCfg.EmailEnabled == enabled {
|
||||
return nil // no change
|
||||
|
||||
@@ -210,7 +210,7 @@ type Manager struct {
|
||||
// this and nothing else: it used to be sent beside the 202 that merely accepted the request, so
|
||||
// an install interrupted five seconds later stayed on the timeline as a completed one.
|
||||
deployDoneHook func(name string, ok bool, detail string)
|
||||
testSeams *migSeams // nil in production; tests inject fakes
|
||||
testSeams *migSeams // nil in production; tests inject fakes
|
||||
// R-51: docker restart policies for DOWN members of mixed stacks. Keyed by
|
||||
// containerName+"|"+state so a transitioned or recreated container re-reads rather than
|
||||
// answering from a stale entry; pruned every refresh to the live container set. Guarded by mu
|
||||
@@ -229,7 +229,7 @@ type Manager struct {
|
||||
updateGuards UpdateGuards // init-only, SetUpdateGuards; nil ⇒ every update is REFUSED
|
||||
updateComposeFn func(dir string, env []string, args ...string) (string, error)
|
||||
updateHealthFn func(ctx context.Context, name string, timeout time.Duration) (bool, string)
|
||||
updateMemoryFn func(newReqMB, newLimitMB, releasedReqMB, releasedLimitMB int) (refusal, warning string)
|
||||
updateMemoryFn func(newReqMB, newLimitMB, releasedReqMB, releasedLimitMB int) (refusal error, warning string)
|
||||
updateDiskFreeFn func() (freeGiB float64, ok bool)
|
||||
updateNowFn func() time.Time
|
||||
updateJournalMu sync.Mutex
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
@@ -163,7 +164,7 @@ func (m *Manager) acquireMigrating() error {
|
||||
m.migrateMu.Lock()
|
||||
defer m.migrateMu.Unlock()
|
||||
if m.migrating {
|
||||
return fmt.Errorf("migráció már folyamatban")
|
||||
return util.MsgError("err.stacks.migracio_mar_folyamatban")
|
||||
}
|
||||
m.migrating = true
|
||||
return nil
|
||||
@@ -242,12 +243,12 @@ func (m *Manager) startMigration(scope, sourcePath, appName, targetPath string,
|
||||
case "app":
|
||||
cfg := m.LoadAppConfigByName(appName)
|
||||
if cfg == nil {
|
||||
return "", fmt.Errorf("alkalmazás nem található: %s", appName)
|
||||
return "", util.MsgError("err.stacks.alkalmazas_nem_talalhato", appName)
|
||||
}
|
||||
j.Source, j.SourceNS = m.appSourceNS(cfg)
|
||||
apps = []string{appName}
|
||||
default:
|
||||
return "", fmt.Errorf("ismeretlen migrációs hatókör: %s", scope)
|
||||
return "", util.MsgError("err.stacks.ismeretlen_migracios_hatokor", scope)
|
||||
}
|
||||
|
||||
j.TargetNS = appbackup.NamespaceRoot(j.Target, m.inGuest(j.Target))
|
||||
@@ -266,7 +267,7 @@ func (m *Manager) startMigration(scope, sourcePath, appName, targetPath string,
|
||||
j.UpdatedAt = time.Now().UTC()
|
||||
m.setJob(j)
|
||||
if err := m.writeJournal(j); err != nil {
|
||||
return "", fmt.Errorf("migrációs napló írása: %w", err)
|
||||
return "", util.MsgError("err.stacks.migracios_naplo_irasa", err)
|
||||
}
|
||||
|
||||
launched = true
|
||||
@@ -359,18 +360,18 @@ func (m *Manager) appsOnDrive(sourcePath string) []string {
|
||||
// migValidate runs the pre-flight checks. Any failure aborts before any side-effect.
|
||||
func (m *Manager) migValidate(j *MigrationJob) error {
|
||||
if m.backupRunning != nil && m.backupRunning() {
|
||||
return fmt.Errorf("biztonsági mentés folyamatban, próbáld újra")
|
||||
return util.MsgError("err.stacks.biztonsagi_mentes_folyamatban_probald_ujra")
|
||||
}
|
||||
if m.testSeams == nil { // real rsync is only required when not using injected copy/verify seams
|
||||
if _, err := exec.LookPath("rsync"); err != nil {
|
||||
return fmt.Errorf("az rsync nem érhető el a rendszeren")
|
||||
return util.MsgError("err.stacks.az_rsync_nem_erheto_el_a")
|
||||
}
|
||||
}
|
||||
if j.Target == j.Source {
|
||||
return fmt.Errorf("a cél és a forrás tároló megegyezik")
|
||||
return util.MsgError("err.stacks.a_cel_es_a_forras_tarolo")
|
||||
}
|
||||
if m.settings == nil || !m.settings.IsStoragePathSchedulable(j.Target) {
|
||||
return fmt.Errorf("a céltároló nem elérhető vagy nem választható")
|
||||
return util.MsgError("err.stacks.a_celtarolo_nem_elerheto_vagy_nem")
|
||||
}
|
||||
// App-dir collision: refuse if any of the app's resolved appdata dir(s) already exists at target.
|
||||
var collide []string
|
||||
@@ -383,7 +384,7 @@ func (m *Manager) migValidate(j *MigrationJob) error {
|
||||
}
|
||||
}
|
||||
if len(collide) > 0 {
|
||||
return fmt.Errorf("ütközés a céltárolón — már létezik ezeknek az alkalmazásoknak az adata: %s", strings.Join(collide, ", "))
|
||||
return util.MsgError("err.stacks.utkozes_a_celtarolon_mar_letezik_ezeknek", strings.Join(collide, ", "))
|
||||
}
|
||||
// Free-space check (best-effort; GetDiskUsage is nil on non-linux).
|
||||
need := m.migSourceSize(j)
|
||||
@@ -391,7 +392,7 @@ func (m *Manager) migValidate(j *MigrationJob) error {
|
||||
if du := system.GetDiskUsage(j.Target); du != nil {
|
||||
avail := int64(du.AvailGB * 1e9)
|
||||
if need > avail {
|
||||
return fmt.Errorf("nincs elég hely a céltárolón (kb. %d GB szükséges, %.1f GB szabad)", need/1_000_000_000, du.AvailGB)
|
||||
return util.MsgError("err.stacks.nincs_eleg_hely_a_celtarolon_kb", need/1_000_000_000, du.AvailGB)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -451,7 +452,7 @@ func (m *Manager) runMigration(ctx context.Context, j *MigrationJob) {
|
||||
case PhaseDone, PhaseAborted:
|
||||
return
|
||||
default:
|
||||
err = fmt.Errorf("ismeretlen migrációs fázis: %q", j.Phase)
|
||||
err = util.MsgError("err.stacks.ismeretlen_migracios_fazis", j.Phase)
|
||||
}
|
||||
if err != nil {
|
||||
m.migAbort(j, err)
|
||||
@@ -484,7 +485,7 @@ func (m *Manager) migAbort(j *MigrationJob, cause error) {
|
||||
func (m *Manager) migStop(j *MigrationJob) error {
|
||||
for _, app := range j.Apps {
|
||||
if err := m.doStop(app); err != nil {
|
||||
return fmt.Errorf("alkalmazás leállítása sikertelen (%s): %w", app, err)
|
||||
return util.MsgError("err.stacks.alkalmazas_leallitasa_sikertelen", app, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -513,13 +514,13 @@ func (m *Manager) migCopy(ctx context.Context, j *MigrationJob) error {
|
||||
}
|
||||
if err := m.copySubtree(ctx, j, src, appbackup.AppDataDir(j.TargetNS, name)); err != nil {
|
||||
u.Error = err.Error()
|
||||
return fmt.Errorf("másolás sikertelen (%s appdata): %w", app, err)
|
||||
return util.MsgError("err.stacks.masolas_sikertelen_appdata", app, err)
|
||||
}
|
||||
}
|
||||
// the app's recovery unit (db-dumps + volume-dumps + compose + manifest)
|
||||
if err := m.copySubtree(ctx, j, appbackup.RecoveryUnitPath(j.SourceNS, app), appbackup.RecoveryUnitPath(j.TargetNS, app)); err != nil {
|
||||
u.Error = err.Error()
|
||||
return fmt.Errorf("másolás sikertelen (%s mentés): %w", app, err)
|
||||
return util.MsgError("err.stacks.masolas_sikertelen_mentes", app, err)
|
||||
}
|
||||
u.State = UnitCopied
|
||||
_ = m.persistJob(j)
|
||||
@@ -530,7 +531,7 @@ func (m *Manager) migCopy(ctx context.Context, j *MigrationJob) error {
|
||||
j.CurrentApp = ""
|
||||
if err := walkMerge(m.logger, j.SourceNS, j.TargetNS, m.appDataSkipSet(j), false, func(b int64) { j.BytesDone += b }); err != nil {
|
||||
u.Error = err.Error()
|
||||
return fmt.Errorf("ügyfél-adatok összefésülése sikertelen: %w", err)
|
||||
return util.MsgError("err.stacks.ugyfel_adatok_osszefesulese_sikertelen", err)
|
||||
}
|
||||
u.State = UnitCopied
|
||||
_ = m.persistJob(j)
|
||||
@@ -557,12 +558,12 @@ func (m *Manager) migVerify(ctx context.Context, j *MigrationJob) error {
|
||||
for _, name := range m.ResolveAppDataDirNames(app) {
|
||||
if err := m.verifySubtree(ctx, appbackup.AppDataDir(j.SourceNS, name), appbackup.AppDataDir(j.TargetNS, name)); err != nil {
|
||||
u.Error = err.Error()
|
||||
return fmt.Errorf("ellenőrzés sikertelen (%s appdata): %w", app, err)
|
||||
return util.MsgError("err.stacks.ellenorzes_sikertelen_appdata", app, err)
|
||||
}
|
||||
}
|
||||
if err := m.verifySubtree(ctx, appbackup.RecoveryUnitPath(j.SourceNS, app), appbackup.RecoveryUnitPath(j.TargetNS, app)); err != nil {
|
||||
u.Error = err.Error()
|
||||
return fmt.Errorf("ellenőrzés sikertelen (%s mentés): %w", app, err)
|
||||
return util.MsgError("err.stacks.ellenorzes_sikertelen_mentes", app, err)
|
||||
}
|
||||
u.State = UnitVerified
|
||||
_ = m.persistJob(j)
|
||||
@@ -573,7 +574,7 @@ func (m *Manager) migVerify(ctx context.Context, j *MigrationJob) error {
|
||||
// assert-only merge walk: every source file has a content-identical counterpart at target.
|
||||
if err := walkMerge(m.logger, j.SourceNS, j.TargetNS, m.appDataSkipSet(j), true, nil); err != nil {
|
||||
u.Error = err.Error()
|
||||
return fmt.Errorf("ügyfél-adatok ellenőrzése sikertelen: %w", err)
|
||||
return util.MsgError("err.stacks.ugyfel_adatok_ellenorzese_sikertelen", err)
|
||||
}
|
||||
u.State = UnitVerified
|
||||
_ = m.persistJob(j)
|
||||
@@ -602,7 +603,7 @@ func (m *Manager) migFlipRedeploy(j *MigrationJob) error {
|
||||
_ = m.persistJob(j)
|
||||
if err := m.doFlipRedeploy(app, j.Target); err != nil {
|
||||
u.Error = err.Error()
|
||||
return fmt.Errorf("újratelepítés sikertelen (%s): %w", app, err)
|
||||
return util.MsgError("err.stacks.ujratelepites_sikertelen", app, err)
|
||||
}
|
||||
u.State = UnitRedeployed
|
||||
_ = m.persistJob(j)
|
||||
@@ -623,11 +624,11 @@ func (m *Manager) migCleanup(j *MigrationJob) error {
|
||||
}
|
||||
for _, name := range m.ResolveAppDataDirNames(app) {
|
||||
if err := os.RemoveAll(appbackup.AppDataDir(j.SourceNS, name)); err != nil {
|
||||
return fmt.Errorf("forrás törlése sikertelen (%s appdata): %w", app, err)
|
||||
return util.MsgError("err.stacks.forras_torlese_sikertelen_appdata", app, err)
|
||||
}
|
||||
}
|
||||
if err := os.RemoveAll(appbackup.RecoveryUnitPath(j.SourceNS, app)); err != nil {
|
||||
return fmt.Errorf("forrás törlése sikertelen (%s mentés): %w", app, err)
|
||||
return util.MsgError("err.stacks.forras_torlese_sikertelen_mentes", app, err)
|
||||
}
|
||||
u.State = UnitCleaned
|
||||
_ = m.persistJob(j)
|
||||
@@ -638,11 +639,11 @@ func (m *Manager) migCleanup(j *MigrationJob) error {
|
||||
// Remove every remaining child of the source namespace (the non-app/customer content).
|
||||
entries, err := os.ReadDir(j.SourceNS)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("forrás névtér olvasása sikertelen: %w", err)
|
||||
return util.MsgError("err.stacks.forras_nevter_olvasasa_sikertelen", err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if err := os.RemoveAll(filepath.Join(j.SourceNS, e.Name())); err != nil {
|
||||
return fmt.Errorf("forrás törlése sikertelen (%s): %w", e.Name(), err)
|
||||
return util.MsgError("err.stacks.forras_torlese_sikertelen", e.Name(), err)
|
||||
}
|
||||
}
|
||||
u.State = UnitCleaned
|
||||
@@ -657,12 +658,12 @@ func (m *Manager) migCleanup(j *MigrationJob) error {
|
||||
func (m *Manager) migCleanupAllowed(j *MigrationJob) error {
|
||||
for key, u := range j.Units {
|
||||
if stateRank(u.State) < stateRank(UnitVerified) {
|
||||
return fmt.Errorf("cleanup gate: a(z) %q egység nincs ellenőrizve (állapot=%s)", key, u.State)
|
||||
return util.MsgError("err.stacks.cleanup_gate_a_z_egyseg_nincs", key, u.State)
|
||||
}
|
||||
}
|
||||
for _, app := range j.Apps {
|
||||
if stateRank(j.Units[app].State) < stateRank(UnitRedeployed) {
|
||||
return fmt.Errorf("cleanup gate: a(z) %q alkalmazás nincs újratelepítve (állapot=%s)", app, j.Units[app].State)
|
||||
return util.MsgError("err.stacks.cleanup_gate_a_z_alkalmazas_nincs", app, j.Units[app].State)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -749,7 +750,7 @@ func (m *Manager) doFlipRedeploy(name, target string) error {
|
||||
return err
|
||||
}
|
||||
if !m.waitHealthy(name) {
|
||||
return fmt.Errorf("az alkalmazás nem indult el az új tárhelyen")
|
||||
return util.MsgError("err.stacks.az_alkalmazas_nem_indult_el_az")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -879,7 +880,7 @@ func rsyncVerify(ctx context.Context, src, dst string) error {
|
||||
}
|
||||
}
|
||||
if len(pending) > 0 {
|
||||
return fmt.Errorf("%d függőben lévő átvitel maradt, pl. %q", len(pending), pending[0])
|
||||
return util.MsgError("err.stacks.fuggoben_levo_atvitel_maradt_pl", len(pending), pending[0])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -979,7 +980,7 @@ func walkMerge(lg *log.Logger, srcNS, dstNS string, skip map[string]bool, assert
|
||||
return nil // dedup / already present
|
||||
}
|
||||
if assertOnly {
|
||||
return fmt.Errorf("a forrásfájlnak nincs azonos másolata a célon: %s", rel)
|
||||
return util.MsgError("err.stacks.a_forrasfajlnak_nincs_azonos_masolata_a", rel)
|
||||
}
|
||||
out := dst
|
||||
if pathExists(dst) {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||
)
|
||||
|
||||
// r553Manager builds a real Manager over a temp stacks dir holding one app fixture, so DeployStack's
|
||||
@@ -116,22 +117,40 @@ func TestR553_AlreadyDeployedCarriesKindAndKeepsItsWords(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The memory refusal is a string built by memoryVerdict from the HOST's real memory, so a unit test
|
||||
// cannot make it fire. Its kind is therefore pinned where it is attached — at the one line that turns
|
||||
// that string into an error. Source-level on purpose, like TestDeployAcceptance_DoesNotClaimTheAppIsInstalled:
|
||||
// the defect this guards is a call written the old way, not a wrong value. The status mapping itself
|
||||
// is covered by TestR553_Deploy_DecisionSurvivesWordingChange with a translated memory message.
|
||||
// The memory refusal is built by memoryVerdict from the HOST's real memory, so a unit test cannot
|
||||
// make it fire. Two halves therefore, and v0.253.0 (R-557) turned the weaker one into the stronger:
|
||||
//
|
||||
// - BEHAVIOUR: the value memoryVerdict now returns is an ERROR it builds itself, so the test can
|
||||
// build the same value and check it — the kind is reachable by errors.Is AND the sentence is the
|
||||
// one the customer used to read, byte for byte, in Hungarian. That is no longer a source check.
|
||||
// - SOURCE: memoryVerdict still hands that error back rather than a sentence. The defect this
|
||||
// guards is a call written the old way, and only the source can show that.
|
||||
func TestR553_MemoryRefusalIsWrappedWithItsKind(t *testing.T) {
|
||||
// The exact value the producer returns, for a 900 MB request that does not fit.
|
||||
e := util.MsgErrorf(ErrNotEnoughMemory, "err.stacks.not_enough_memory", 900, 100, 2048, 1436, 512)
|
||||
|
||||
if !errors.Is(e, ErrNotEnoughMemory) {
|
||||
t.Error("the memory refusal does not carry ErrNotEnoughMemory — the API would answer 500 " +
|
||||
"for a refusal the customer can act on")
|
||||
}
|
||||
const wantHU = "Nincs elég memória az alkalmazás telepítéséhez. Szükséges: 900 MB, Elérhető: 100 MB " +
|
||||
"(összesen: 2048 MB, ebből 1436 MB használt, 512 MB rendszer számára fenntartva)"
|
||||
if e.Error() != wantHU {
|
||||
t.Errorf("the Hungarian refusal moved:\n got %q\nwant %q", e.Error(), wantHU)
|
||||
}
|
||||
if en := util.ErrText("en", e); en == wantHU || en == "" {
|
||||
t.Errorf("the refusal has no English of its own: %q", en)
|
||||
}
|
||||
|
||||
src, err := os.ReadFile("deploy.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := string(src)
|
||||
if !strings.Contains(body, `util.KindError(ErrNotEnoughMemory, refusal)`) {
|
||||
t.Error("the memory refusal no longer carries ErrNotEnoughMemory — the API would answer 500 " +
|
||||
"for a refusal the customer can act on, and translating the sentence would hide it completely")
|
||||
if !strings.Contains(body, `util.MsgErrorf(ErrNotEnoughMemory, "err.stacks.not_enough_memory"`) {
|
||||
t.Error("memoryVerdict no longer builds its refusal with ErrNotEnoughMemory and its key")
|
||||
}
|
||||
if strings.Contains(body, `errors.New(refusal)`) {
|
||||
t.Error("the memory refusal is back to a bare errors.New: its kind is gone (R-553)")
|
||||
if strings.Contains(body, `errors.New(refusal)`) || strings.Contains(body, `fmt.Errorf(refusal)`) {
|
||||
t.Error("the memory refusal is back to a bare error: its kind is gone (R-553)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package stacks
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -249,11 +250,11 @@ func (m *Manager) SetSMBPassword(password string) error {
|
||||
m.infraMu.Lock()
|
||||
defer m.infraMu.Unlock()
|
||||
if m.settings == nil {
|
||||
return fmt.Errorf("a beállítások nem érhetők el")
|
||||
return util.MsgError("err.stacks.a_beallitasok_nem_erhetok_el")
|
||||
}
|
||||
smb := m.settings.GetSMBSettings()
|
||||
if !smb.Enabled {
|
||||
return fmt.Errorf("a hálózati megosztás nincs bekapcsolva")
|
||||
return util.MsgError("err.stacks.a_halozati_megosztas_nincs_bekapcsolva")
|
||||
}
|
||||
dir := m.sambaDir()
|
||||
data := m.sambaRenderData(smb)
|
||||
@@ -264,7 +265,7 @@ func (m *Manager) SetSMBPassword(password string) error {
|
||||
// safe: `security = user` + `map to guest = never` means nothing is reachable until this lands.
|
||||
if !m.sambaIsRunning() {
|
||||
if err := m.sambaUp(dir); err != nil {
|
||||
return fmt.Errorf("a megosztás szolgáltatás indítása sikertelen: %w", err)
|
||||
return util.MsgError("err.stacks.a_megosztas_szolgaltatas_inditasa_sikertelen", err)
|
||||
}
|
||||
}
|
||||
if err := m.sambaSetPassword(password); err != nil {
|
||||
@@ -310,7 +311,7 @@ func (m *Manager) DisableSamba() error {
|
||||
return nil // never deployed — nothing to stop
|
||||
}
|
||||
if _, err := m.composeExec(dir, "down"); err != nil {
|
||||
return fmt.Errorf("a megosztás leállítása sikertelen: %w", err)
|
||||
return util.MsgError("err.stacks.a_megosztas_leallitasa_sikertelen", err)
|
||||
}
|
||||
m.logger.Printf("[INFO] [samba] stack stopped (passdb volume and all shared folders kept)")
|
||||
return nil
|
||||
|
||||
@@ -257,9 +257,21 @@ func fillHoldReason(g UpdateGuards, st *Stack) {
|
||||
type UpdateRefusal struct {
|
||||
Reason string
|
||||
Message string
|
||||
// Cause carries the refusal as an ERROR when the producer made one (v0.253.0, R-557). A
|
||||
// util.MsgError there knows its bundle key, so `api.Router.errText` renders the refusal in the
|
||||
// household's language; Message stays the Hungarian fallback for every refusal built from a
|
||||
// literal. Unwrap is what lets errors.Is and util.AsMsg see through this wrapper.
|
||||
Cause error
|
||||
}
|
||||
|
||||
func (r *UpdateRefusal) Error() string { return r.Message }
|
||||
func (r *UpdateRefusal) Error() string {
|
||||
if r.Cause != nil {
|
||||
return r.Cause.Error()
|
||||
}
|
||||
return r.Message
|
||||
}
|
||||
|
||||
func (r *UpdateRefusal) Unwrap() error { return r.Cause }
|
||||
|
||||
func (m *Manager) now() time.Time {
|
||||
if m.updateNowFn != nil {
|
||||
@@ -273,6 +285,13 @@ func (m *Manager) refuseUpdate(name, reason, msg, detail string) *UpdateRefusal
|
||||
return &UpdateRefusal{Reason: reason, Message: msg}
|
||||
}
|
||||
|
||||
// refuseUpdateErr is refuseUpdate for a refusal the producer already built as an error — it keeps the
|
||||
// error whole, so its kind and its message key both survive to the API.
|
||||
func (m *Manager) refuseUpdateErr(name, reason string, cause error, detail string) *UpdateRefusal {
|
||||
m.logger.Printf("[ERROR] [stacks] update %s REFUSED (%s): %s", name, reason, detail)
|
||||
return &UpdateRefusal{Reason: reason, Message: cause.Error(), Cause: cause}
|
||||
}
|
||||
|
||||
// UpdatePreflight runs every CHEAP refusal (slice 4 Part 1 + the precondition's existence), in order,
|
||||
// and returns the first. Nothing is moved and nothing is recorded by it. The router calls it before
|
||||
// recording the customer's intent, so an update that was never going to happen records nothing.
|
||||
@@ -350,8 +369,8 @@ func (m *Manager) updateMemoryRefusal(name string, st *Stack) *UpdateRefusal {
|
||||
if verdict == nil {
|
||||
verdict = m.memoryVerdict
|
||||
}
|
||||
if refusal, _ := verdict(newReq, newLim, oldReq, oldLim); refusal != "" {
|
||||
return m.refuseUpdate(name, "memory", refusal, fmt.Sprintf("new_req=%dMB replacing %dMB does not fit", newReq, oldReq))
|
||||
if refusal, _ := verdict(newReq, newLim, oldReq, oldLim); refusal != nil {
|
||||
return m.refuseUpdateErr(name, "memory", refusal, fmt.Sprintf("new_req=%dMB replacing %dMB does not fit", newReq, oldReq))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -20,22 +20,22 @@ import (
|
||||
var slice4T0 = time.Date(2026, 9, 13, 10, 0, 0, 0, time.UTC)
|
||||
|
||||
type fakeGuards struct {
|
||||
mu sync.Mutex
|
||||
calls []string
|
||||
held bool
|
||||
holdWhy string
|
||||
busy bool
|
||||
mu sync.Mutex
|
||||
calls []string
|
||||
held bool
|
||||
holdWhy string
|
||||
busy bool
|
||||
// points are the copies the backup side holds, in tier order (R-475); pointsAfterBackup replaces
|
||||
// them when BackupNow succeeds (nil = the backup changed nothing).
|
||||
points []UpdateRestorePoint
|
||||
pointsAfterBackup []UpdateRestorePoint
|
||||
cannotBackUp bool
|
||||
backupErr error
|
||||
dumpErr error
|
||||
holdErr error
|
||||
holdRP UpdateRestorePoint
|
||||
pinAtDump string
|
||||
stackDir string
|
||||
backupErr error
|
||||
dumpErr error
|
||||
holdErr error
|
||||
holdRP UpdateRestorePoint
|
||||
pinAtDump string
|
||||
stackDir string
|
||||
}
|
||||
|
||||
func (f *fakeGuards) note(c string) { f.mu.Lock(); f.calls = append(f.calls, c); f.mu.Unlock() }
|
||||
@@ -129,7 +129,7 @@ func newSlice4Manager(t *testing.T) (*Manager, string, *fakeGuards, *composeRec)
|
||||
m.updateGuards = g
|
||||
m.updateComposeFn = c.fn
|
||||
m.updateHealthFn = func(context.Context, string, time.Duration) (bool, string) { return true, "fake healthy" }
|
||||
m.updateMemoryFn = func(int, int, int, int) (string, string) { return "", "" }
|
||||
m.updateMemoryFn = func(int, int, int, int) (error, string) { return nil, "" }
|
||||
m.updateDiskFreeFn = func() (float64, bool) { return 50, true }
|
||||
m.updateNowFn = func() time.Time { return slice4T0 } // R-457: the SAME clock the age check reads
|
||||
m.execFn = func(string, ...string) (string, error) { return "", nil }
|
||||
@@ -338,8 +338,8 @@ func TestSlice4_D_CheapRefusals(t *testing.T) {
|
||||
if err := os.WriteFile(filepath.Join(catDir, ".felhom.yml"), []byte("resources:\n mem_request: 900M\n"), 0o644); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
m.updateMemoryFn = func(newReq, _, _, _ int) (string, string) {
|
||||
return fmt.Sprintf("Nincs elég memória (%d MB)", newReq), ""
|
||||
m.updateMemoryFn = func(newReq, _, _, _ int) (error, string) {
|
||||
return fmt.Errorf("Nincs elég memória (%d MB)", newReq), ""
|
||||
}
|
||||
}, "memory", "Nincs elég memória (900 MB)"},
|
||||
{"disk", func(m *Manager, _ *fakeGuards, _ string) {
|
||||
|
||||
Reference in New Issue
Block a user