package signedjobs import ( "bytes" "context" "encoding/json" "fmt" "log/slog" "regexp" "strconv" "strings" "time" "gitea.dooplex.hu/admin/felhom-agent/internal/localapi" "gitea.dooplex.hu/admin/felhom-agent/internal/proxmox" ) // opAddInternalPlace is the op class this executor serves (mirrors reconcile.ClassAddInternalPlace). const opAddInternalPlace = "add_internal_place" // InternalPlaceHelper is the root helper's path (configs/felhom-internal-place, from the config bundle). const InternalPlaceHelper = "/usr/local/sbin/felhom-internal-place" var reHelperResult = regexp.MustCompile(`^(created|existing|formatted) uuid=([0-9a-fA-F-]{36}) gib=([0-9]+)$`) // InternalPlaceExecutor adds the internal data place to an OLD one-disk box (felhom.eu architecture/16 §9: „one-disk // boxes get no internal place by themselves; adding one later is a signed operator job, not a household button"). // It runs the SAME root helper the installer runs — `felhom-internal-place create 0` (0 GiB planned: the guest's // volumes already exist and are counted) — writes the record the agent mounts from, and asks the local API to mount // it now. Every safety rule (the ruled size, never format what holds anything) lives in the helper, as root. // // The op carries no parameters: the size is the rule's, never the signer's. Destructive-class by default (the // classifier's fail-safe; ClassAddInternalPlace documents it): it takes thin space on the system disk, so only an // operator signature may start it. type InternalPlaceExecutor struct { runner proxmox.Runner recordFile string ensure func(context.Context) // late-bound: the local API server is built after the jobs runner logger *slog.Logger } // NewInternalPlaceExecutor wires the executor. ensure may be nil at construction and set later through SetEnsure. func NewInternalPlaceExecutor(runner proxmox.Runner, recordFile string, logger *slog.Logger) *InternalPlaceExecutor { if logger == nil { logger = slog.Default() } return &InternalPlaceExecutor{runner: runner, recordFile: recordFile, logger: logger} } // SetEnsure late-binds the mount step (localapi.Server.EnsureInternalPlace). func (e *InternalPlaceExecutor) SetEnsure(f func(context.Context)) { e.ensure = f } // Execute implements signedjobs.Executor for add_internal_place. func (e *InternalPlaceExecutor) Execute(ctx context.Context, op string, params json.RawMessage) error { if op != opAddInternalPlace { return ErrNoExecutor } if p := bytes.TrimSpace(params); len(p) > 0 && !bytes.Equal(p, []byte("{}")) && !bytes.Equal(p, []byte("null")) { return fmt.Errorf("add_internal_place: takes no parameters (the size is the ruled rule's), got %s", p) } if e.runner == nil || e.recordFile == "" { return fmt.Errorf("add_internal_place: not wired on this host (no privileged runner or state dir)") } start := time.Now() e.logger.Warn("add_internal_place: executing operator-signed job (the internal data place, `16` §9)") out, stderr, err := e.runner.Run(ctx, InternalPlaceHelper, "create", "0") line := strings.TrimSpace(string(out)) if err != nil { return fmt.Errorf("add_internal_place: helper refused: %v: %s", err, strings.TrimSpace(string(stderr))) } if strings.HasPrefix(line, "skipped ") { // Not an error the operator can retry away: the pool has no room by the ruled rule. Fail the job with the // helper's own numbers so the hub shows why. return fmt.Errorf("add_internal_place: %s", line) } m := reHelperResult.FindStringSubmatch(line) if m == nil { return fmt.Errorf("add_internal_place: unexpected helper output %q", line) } gib, _ := strconv.Atoi(m[3]) rec := localapi.InternalPlaceRecord{FSUUID: m[2], GiB: gib, MadeBy: "signed-job", MadeAt: time.Now().UTC().Format(time.RFC3339)} if err := localapi.WriteInternalPlace(e.recordFile, rec); err != nil { return fmt.Errorf("add_internal_place: writing the record: %w", err) } if e.ensure != nil { e.ensure(ctx) } e.logger.Warn("add_internal_place: complete", "result", m[1], "fs_uuid", m[2], "gib", gib, "duration_ms", time.Since(start).Milliseconds()) return nil }