Files
iasysadmin/internal/tools/retry.go
T
2026-09-27 16:04:02 -06:00

125 lines
4.4 KiB
Go

package tools
import (
"context"
"fmt"
"os"
"regexp"
"strings"
"sync"
"time"
"sysagent/internal/policy"
)
// transientRule describe un error transitorio que justifica reintentar (§54).
//
// Solo se incluyen errores que garantizan que el comando NO llegó a hacer
// su trabajo (no obtuvo el bloqueo, no resolvió el nombre), para que
// reintentar sea seguro incluso con comandos que modifican el sistema.
type transientRule struct {
re *regexp.Regexp
what string
backoff []time.Duration // una espera por reintento
}
var transientRules = []transientRule{
{
re: regexp.MustCompile(`(?i)could not get lock /var/lib/(dpkg|apt)|unable to acquire the dpkg frontend lock|unable to lock (the )?(administration|download) directory|waiting for cache lock|is another process using it\?`),
what: "package manager lock held by another process",
backoff: []time.Duration{5 * time.Second, 15 * time.Second, 30 * time.Second},
},
{
re: regexp.MustCompile(`(?i)temporary failure in name resolution|temporary failure resolving|could not resolve host|name or service not known`),
what: "temporary DNS failure",
backoff: []time.Duration{2 * time.Second, 5 * time.Second},
},
}
// retryScale permite acortar las esperas en tests.
var retryScale = time.Duration(1)
// runWithRetry ejecuta spec y reintenta con backoff ante errores
// transitorios conocidos. Nunca reintenta indefinidamente: cada regla tiene
// un número máximo de reintentos. Añade también la pista de escalada de
// privilegios cuando falla por permisos (§46).
func runWithRetry(ctx context.Context, env *Env, spec CommandSpec, display, cwd string, risks []policy.Risk) Result {
var notes []string
r := RunCommand(ctx, spec)
attempts := map[int]int{}
for {
if r.ExitCode == 0 || r.TimedOut || ctx.Err() != nil {
break
}
rule := -1
out := r.Stderr + "\n" + r.Stdout
for i, tr := range transientRules {
if tr.re.MatchString(out) {
rule = i
break
}
}
if rule < 0 || attempts[rule] >= len(transientRules[rule].backoff) {
break
}
base := transientRules[rule].backoff[attempts[rule]]
wait := base * retryScale
attempts[rule]++
notes = append(notes, fmt.Sprintf("retry %d/%d after %s: %s", attempts[rule], len(transientRules[rule].backoff), base, transientRules[rule].what))
env.Log.Infof("%s: %s; retrying in %s", display, transientRules[rule].what, base)
select {
case <-ctx.Done():
case <-time.After(wait):
}
if ctx.Err() != nil {
break
}
r = RunCommand(ctx, spec)
}
res := commandResult(display, cwd, r)
if len(notes) > 0 {
res.Output += "\n--- retries ---\n" + strings.Join(notes, "\n")
res.Audit = append(res.Audit, "retries", fmt.Sprint(len(notes)))
}
if r.ExitCode != 0 && permissionDeniedRe.MatchString(r.Stderr+r.Stdout) {
res.Output += "\n" + escalationHint()
}
return res
}
var permissionDeniedRe = regexp.MustCompile(`(?i)permission denied|operation not permitted|must be (run as )?root|are you root|requires (superuser|root) privileges|superuser privileges required|only root can`)
var (
escalationOnce sync.Once
escalationText string
)
// escalationHint explica al modelo si puede elevar privilegios (§46): no se
// asume root porque el proyecto lo pida; se comprueba qué hay disponible y
// si funciona sin contraseña (sudo -n / doas -n no preguntan nunca).
func escalationHint() string {
if os.Geteuid() == 0 {
return "hint: the agent already runs as root (euid=0); the denial comes from something else (read-only filesystem, SELinux/AppArmor, immutable attribute, capabilities or a container restriction)."
}
escalationOnce.Do(func() {
var parts []string
for _, tool := range []string{"sudo", "doas"} {
if _, err := execLookPath(tool); err != nil {
continue
}
r := RunCommand(context.Background(), CommandSpec{Name: tool, Args: []string{"-n", "true"}, Timeout: 5 * time.Second})
parts = append(parts, fmt.Sprintf("%s (passwordless: %t)", tool, r.ExitCode == 0))
}
if _, err := execLookPath("su"); err == nil {
parts = append(parts, "su (needs the target user's password)")
}
avail := "none available"
if len(parts) > 0 {
avail = strings.Join(parts, ", ")
}
escalationText = fmt.Sprintf("hint: permission denied and the agent is not running as root (uid=%d euid=%d). Privilege escalation: %s. Use it only if the policy and the task justify it; if a password is required, ask the operator with ask_secret (CLI only).", os.Getuid(), os.Geteuid(), avail)
})
return escalationText
}