package release import ( "encoding/json" "errors" "os" "slices" "strings" "github.com/carlosframework/platform/internal/rootstate" ) const RootHistoryStatusDir = "/var/lib/carlos-release/history-status" type HistoryHostStatus struct { RetirementDigests map[string]string `json:"retirement_digests,omitempty"` HistoryReconstructionHost Programs historyPrograms `json:"programs"` RefusalStatus historyRefusals `json:"refusal_status"` Recoveries []HistoryRecovery `json:"recoveries,omitempty"` RecoveryDigests map[string]string `json:"recovery_digests,omitempty"` RetiredRecoveries []string `json:"retired_recoveries,omitempty"` Attempt *HistoryAttempt `json:"attempt,omitempty"` } type HistoryRecoverySources struct { Known bool `json:"known"` Unresolved []HistoryRecovery `json:"unresolved,omitempty"` } type HistoryMigrationStatus struct { HistoryReconstructionStatus Schema int `json:"schema"` RecoverySources *HistoryRecoverySources `json:"recovery_sources,omitempty"` Account string `json:"account"` App string `json:"app"` AppRevision uint64 `json:"app_revision"` AppComplete bool `json:"app_complete"` BakeRevision uint64 `json:"bake_revision"` BakeComplete uint64 `json:"bake_complete"` Hosts []HistoryHostStatus `json:"hosts"` Clock FirstSeen `json:"clock"` PendingDigest string `json:"pending_digest,omitempty"` PendingGrant *HistoryReconstruction `json:"pending_grant,omitempty"` } func HistoryStatusKey(acct, app string) string { return historyAppKey(acct, app) + ".status" } func (h *RootHistory) MigrationStatus(b HistoryBinding, resolve func(HistoryBinding) (string, error)) (HistoryMigrationStatus, error) { var out HistoryMigrationStatus if !b.valid() || resolve == nil { return out, os.ErrPermission } status, err := h.ReconstructionStatus() if err != nil { return out, err } out = HistoryMigrationStatus{HistoryReconstructionStatus: status, Schema: 1, Account: b.Account, App: b.App} state, err := h.reconstructionState() if err != nil { return out, err } hosts := []string{b.Host} if state.Pending != nil { raw, err := json.Marshal(state.Pending.Grant) if err != nil || reconstructionDigest(raw) != state.Pending.Digest { return out, os.ErrPermission } if state.Pending.Grant.Account == b.Account && state.Pending.Grant.App == b.App { out.PendingDigest = state.Pending.Digest grant := state.Pending.Grant out.PendingGrant = &grant for _, host := range grant.Hosts { hosts = append(hosts, host.Host) } } } read := func(key string, v any, valid func() bool) error { raw, err := h.Store.Read(key) if errors.Is(err, os.ErrNotExist) { return nil } if err != nil { return err } if len(raw) > rootstate.MaxRecord || strictCanonicalJSON(raw, v) != nil || !valid() { return os.ErrPermission } return h.Store.Write(key, raw) } var app historyApp if err := read(historyAppKey(b.Account, b.App), &app, func() bool { return validHistoryApp(app) && app.Account == b.Account && app.App == b.App }); err != nil { return out, err } out.AppRevision, out.AppComplete = app.Revision, app.Complete hosts = append(hosts, app.Hosts...) var bake rootBakeRecord if err := read(rootBakeKey, &bake, func() bool { return validRootBake(bake) }); err != nil { return out, err } out.BakeRevision, out.BakeComplete = bake.Revision, bake.Complete[prodKey(b.Account, b.App)] out.Clock = FirstSeen{Production: map[string]*prodMemory{}, Holds: map[string]*holdMemory{}} if p := bake.Clock.Production[prodKey(b.Account, b.App)]; p != nil { out.Clock.Production[prodKey(b.Account, b.App)] = p } for key, hold := range bake.Clock.Holds { if strings.HasPrefix(key, holdKeyPrefix(b.Account, b.App)) { out.Clock.Holds[key] = hold } } names, err := h.Store.Names() if err != nil { return out, err } for _, name := range names { if name == rootBakeKey || !strings.HasSuffix(name, ".json") { continue } var record historyRecord if err := read(name, &record, func() bool { return validHistory(record, record.Binding) && name == historyKey(record.Binding) }); err != nil { return out, err } if record.Binding.Account == b.Account && record.Binding.App == b.App { hosts = append(hosts, record.Binding.Host) } } outcomes, err := h.recoveryOutcomes() if err != nil { return out, err } for _, r := range outcomes { if r.Binding.Account == b.Account && r.Binding.App == b.App { hosts = append(hosts, r.Binding.Host) } } slices.Sort(hosts) hosts = slices.Compact(hosts) if len(hosts) > historyLimit { return out, os.ErrPermission } for _, host := range hosts { binding := HistoryBinding{Host: host, Account: b.Account, App: b.App} source, err := resolve(binding) if err != nil { return out, err } if source == "" { return out, os.ErrPermission } record, err := h.readRecord(binding) if err != nil && !errors.Is(err, os.ErrNotExist) { return out, err } var recoveries []HistoryRecovery digests := map[string]string{} var retired []string retirementDigests := map[string]string{} for _, r := range outcomes { if r.Binding == binding { recoveries = append(recoveries, r) digests[r.Attempt.ID] = historyRecoveryDigest(r) if state.Pending == nil { digest, err := h.recoveryRetirementDigest(r, outcomes) if err != nil { return out, err } if digest != "" { retirementRaw, err := h.Store.Read(historyRetirementKey(r)) var grant HistoryRecoveryRetirement if err != nil { return out, err } if reconstructionDigest(retirementRaw) != digest || strictCanonicalJSON(retirementRaw, &grant) != nil { return out, os.ErrPermission } if grant.Source != source { continue } retirementDigests[r.Attempt.ID] = digest retired = append(retired, r.Attempt.ID) } } } } out.Hosts = append(out.Hosts, HistoryHostStatus{RetirementDigests: retirementDigests, Recoveries: recoveries, RecoveryDigests: digests, RetiredRecoveries: retired, HistoryReconstructionHost: HistoryReconstructionHost{Host: host, Source: source, Revision: record.Revision, Refusals: record.Refusals.Values}, Programs: record.Programs, RefusalStatus: record.Refusals, Attempt: record.Attempt}) } for _, host := range out.Hosts { source, err := resolve(HistoryBinding{Host: host.Host, Account: b.Account, App: b.App}) if err != nil { return out, err } if source != host.Source { return out, os.ErrPermission } } return out, nil } func (h *RootHistory) MigrationStatusWithSources(b HistoryBinding, resolve func(HistoryBinding) (string, error), sources ...HistoryRecoverySources) (HistoryMigrationStatus, error) { if len(sources) > 1 { return HistoryMigrationStatus{}, os.ErrPermission } view, err := h.MigrationStatus(b, resolve) if err != nil { return HistoryMigrationStatus{}, err } if len(sources) == 1 { effective := HistoryRecoverySources{Known: sources[0].Known} if len(sources[0].Unresolved) > historyLimit { return HistoryMigrationStatus{}, os.ErrPermission } for _, r := range sources[0].Unresolved { if !validHistoryRecovery(r) || r.Binding.Account != b.Account || r.Binding.App != b.App { return HistoryMigrationStatus{}, os.ErrPermission } if r.Settled { retired, err := h.RecoveryRetired(r) if err != nil { effective.Known = false } if err == nil && retired { continue } } effective.Unresolved = append(effective.Unresolved, r) for i := range view.Hosts { if view.Hosts[i].Host == r.Binding.Host { delete(view.Hosts[i].RetirementDigests, r.Attempt.ID) view.Hosts[i].RetiredRecoveries = slices.DeleteFunc(view.Hosts[i].RetiredRecoveries, func(id string) bool { return id == r.Attempt.ID }) } } } view.RecoverySources = &effective } return view, nil } func (h *RootHistory) ProjectMigrationStatus(dst rootstate.Store, b HistoryBinding, resolve func(HistoryBinding) (string, error), sources ...HistoryRecoverySources) error { if dst == nil { return os.ErrPermission } view, err := h.MigrationStatusWithSources(b, resolve, sources...) if err != nil { return err } raw, err := json.Marshal(view) if err != nil { return err } if len(raw) > rootstate.MaxRecord { return os.ErrPermission } return dst.Write(HistoryStatusKey(b.Account, b.App), raw) }