package store import ( "crypto/sha256" "encoding/hex" "fmt" "sort" "strings" ) // ComputeModuleInputHash fingerprints module config and child entries so snapshots // invalidate on CRUD without re-reading every child at render time. func ComputeModuleInputHash(st Backend, tenantID string, mod *Module) (string, error) { if st == nil || mod == nil { return "", fmt.Errorf("store: module hash: missing store or module") } h := sha256.New() _, _ = fmt.Fprintf(h, "type=%s\n", strings.TrimSpace(mod.Type)) _, _ = fmt.Fprintf(h, "enabled=%t\n", mod.Enabled) if mod.DefaultCommunityID != nil { _, _ = fmt.Fprintf(h, "default_community=%s\n", strings.TrimSpace(*mod.DefaultCommunityID)) } _, _ = fmt.Fprintf(h, "doh_policy=%s\n", NormalizeDohResolverPolicy(mod.DohResolverPolicy)) for _, pid := range mod.EffectiveDohProfileIDs() { _, _ = fmt.Fprintf(h, "doh_profile=%s\n", pid) if prof, err := st.GetDohProfile(tenantID, pid); err == nil && prof != nil { _, _ = fmt.Fprintf(h, "doh_url=%s\n", strings.TrimSpace(prof.URL)) if prof.TimeoutMs != nil { _, _ = fmt.Fprintf(h, "doh_timeout=%d\n", *prof.TimeoutMs) } } } switch mod.Type { case "IP_RANGES": list, err := st.ListIPRangeEntries(tenantID, mod.ID) if err != nil { return "", err } sort.Slice(list, func(i, j int) bool { return list[i].Prefix < list[j].Prefix }) for _, e := range list { comm := "" if e.CommunityID != nil { comm = *e.CommunityID } _, _ = fmt.Fprintf(h, "ip=%s|c=%s\n", e.Prefix, comm) } case "AS_PREFIXES": list, err := st.ListASEntries(tenantID, mod.ID) if err != nil { return "", err } sort.Slice(list, func(i, j int) bool { return list[i].ASN < list[j].ASN }) for _, e := range list { comm := "" if e.CommunityID != nil { comm = *e.CommunityID } _, _ = fmt.Fprintf(h, "as=%d|c=%s\n", e.ASN, comm) } case "CDN_CIDRS": list, err := st.ListCDNSources(tenantID, mod.ID) if err != nil { return "", err } sort.Slice(list, func(i, j int) bool { return list[i].ID < list[j].ID }) for _, s := range list { comm := "" if s.CommunityID != nil { comm = *s.CommunityID } interval := 0 if s.RefreshIntervalSec != nil { interval = *s.RefreshIntervalSec } _, _ = fmt.Fprintf(h, "cdn=%s|url=%s|kind=%s|path=%s|c=%s|etag=%s|interval=%d\n", s.ID, strings.TrimSpace(s.URL), s.SourceKind, strings.TrimSpace(s.PrefixPath), comm, strings.TrimSpace(s.Etag), interval) } case "DOMAINS": list, err := st.ListDomainEntries(tenantID, mod.ID) if err != nil { return "", err } sort.Slice(list, func(i, j int) bool { return list[i].FQDN < list[j].FQDN }) for _, e := range list { comm := "" if e.CommunityID != nil { comm = *e.CommunityID } _, _ = fmt.Fprintf(h, "dom=%s|c=%s\n", strings.TrimSpace(e.FQDN), comm) } default: _, _ = fmt.Fprintf(h, "unknown_type=%s\n", mod.Type) } return hex.EncodeToString(h.Sum(nil)), nil } // TouchModuleInputHash recomputes and stores module.input_hash (ARCH-01: hash lives in store). func TouchModuleInputHash(st Backend, tenantID, moduleID string) { if st == nil { return } mod, err := st.GetModule(tenantID, moduleID) if err != nil || mod == nil { return } h, err := ComputeModuleInputHash(st, tenantID, mod) if err != nil { return } _ = st.SetModuleInputHash(tenantID, moduleID, h) } // CDNPatchAffectsInputHash reports whether a CDN source patch changes ingest fingerprint // fields (URL/kind/path/community/interval). ETag and last_refreshed_at do not. func CDNPatchAffectsInputHash(patch *CDNSourcePatch) bool { if patch == nil { return false } return patch.SourceKind != nil || patch.URL != nil || patch.PrefixPath != nil || patch.CommunityID != nil || patch.RefreshIntervalSec != nil }