Pulse/internal/actionplanner/planner.go
2026-06-13 20:49:27 +01:00

803 lines
26 KiB
Go

package actionplanner
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"math"
"reflect"
"regexp"
"sort"
"strings"
"time"
unified "github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
)
const DefaultPlanTTL = 5 * time.Minute
var ErrCapabilityNotFound = errors.New("resource capability is not advertised")
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
if e == nil {
return ""
}
if e.Field == "" {
return e.Message
}
return e.Field + ": " + e.Message
}
func AsValidationError(err error) (*ValidationError, bool) {
var target *ValidationError
if errors.As(err, &target) {
return target, true
}
return nil, false
}
type Planner struct {
Now func() time.Time
TTL time.Duration
}
func (p Planner) Plan(req unified.ActionRequest, resource unified.Resource) (unified.ActionPlan, error) {
req = normalizeRequest(req)
if err := validateRequest(req); err != nil {
return unified.ActionPlan{}, err
}
resourceID := unified.CanonicalResourceID(resource.ID)
if resourceID == "" {
return unified.ActionPlan{}, &ValidationError{Field: "resourceId", Message: "resource has no canonical id"}
}
if req.ResourceID != resourceID {
return unified.ActionPlan{}, &ValidationError{Field: "resourceId", Message: "request resource does not match planned resource"}
}
capability, ok := findCapability(resource.Capabilities, req.CapabilityName)
if !ok {
return unified.ActionPlan{}, ErrCapabilityNotFound
}
if err := validateParams(req.Params, capability.Params); err != nil {
return unified.ActionPlan{}, err
}
plannedAt := p.now()
ttl := p.ttl()
policy := normalizeApprovalPolicy(capability.MinimumApprovalLevel)
requiresApproval := policy == unified.ApprovalAdmin || policy == unified.ApprovalMultiFactor
resourceVersion := ResourceVersion(resource)
policyVersion := PolicyVersion(capability)
actionID := actionID(req, resourceVersion, policyVersion)
plan := unified.ActionPlan{
ActionID: actionID,
RequestID: req.RequestID,
Allowed: true,
RequiresApproval: requiresApproval,
ApprovalPolicy: policy,
PredictedBlastRadius: predictedBlastRadius(resource),
RollbackAvailable: false,
Message: planMessage(resource, capability, policy),
PlannedAt: plannedAt,
ExpiresAt: plannedAt.Add(ttl),
ResourceVersion: resourceVersion,
PolicyVersion: policyVersion,
Preflight: buildPreflight(resource, capability, req, actionID, policy, plannedAt),
}
plan.PlanHash = planHash(req, plan)
plan.Preflight = unified.NormalizeActionPreflight(plan.Preflight, req, plan)
return plan, nil
}
func ResourceVersion(resource unified.Resource) string {
payload := struct {
ID string `json:"id"`
Type unified.ResourceType `json:"type"`
Technology string `json:"technology,omitempty"`
Name string `json:"name"`
Status unified.ResourceStatus `json:"status"`
AISafeSummary string `json:"aiSafeSummary,omitempty"`
Sources []unified.DataSource `json:"sources,omitempty"`
ParentID string `json:"parentId,omitempty"`
Capabilities []normalizedCapabilityForResourceHash `json:"capabilities,omitempty"`
Relationships []normalizedRelationship `json:"relationships,omitempty"`
RecentChanges []normalizedChange `json:"recentChanges,omitempty"`
IncidentCode string `json:"incidentCode,omitempty"`
IncidentStatus storageIncidentHashFields `json:"incidentStatus,omitempty"`
SourceStatus map[unified.DataSource]sourceStatusHash `json:"sourceStatus,omitempty"`
}{
ID: unified.CanonicalResourceID(resource.ID),
Type: unified.CanonicalResourceType(resource.Type),
Technology: strings.TrimSpace(resource.Technology),
Name: strings.TrimSpace(resource.Name),
Status: resource.Status,
AISafeSummary: strings.TrimSpace(resource.AISafeSummary),
Sources: normalizeDataSources(resource.Sources),
Capabilities: normalizeCapabilitiesForResourceHash(resource.Capabilities),
Relationships: normalizeRelationships(unified.ResourceRelationshipsWithCanonicalParent(resource)),
RecentChanges: normalizeChanges(resource.RecentChanges),
IncidentCode: strings.TrimSpace(resource.IncidentCode),
IncidentStatus: storageIncidentHashFields{
Severity: string(resource.IncidentSeverity),
Summary: strings.TrimSpace(resource.IncidentSummary),
Category: strings.TrimSpace(resource.IncidentCategory),
Priority: resource.IncidentPriority,
},
SourceStatus: normalizeSourceStatus(resource.SourceStatus),
}
if resource.ParentID != nil {
payload.ParentID = unified.CanonicalResourceID(*resource.ParentID)
}
return "resource:sha256:" + hashJSON(payload, 12)
}
func PolicyVersion(capability unified.ResourceCapability) string {
payload := struct {
Name string `json:"name"`
Type unified.CapabilityType `json:"type"`
Description string `json:"description"`
MinimumApprovalLevel unified.ActionApprovalLevel `json:"minimumApprovalLevel"`
Platform string `json:"platform,omitempty"`
InternalHandler string `json:"internalHandler,omitempty"`
Params []unified.CapabilityParam `json:"params,omitempty"`
NormalizedPolicy unified.ActionApprovalLevel `json:"normalizedPolicy"`
ParamNames []string `json:"paramNames,omitempty"`
}{
Name: strings.TrimSpace(capability.Name),
Type: capability.Type,
Description: strings.TrimSpace(capability.Description),
MinimumApprovalLevel: capability.MinimumApprovalLevel,
Platform: strings.TrimSpace(capability.Platform),
InternalHandler: strings.TrimSpace(capability.InternalHandler),
Params: normalizeCapabilityParams(capability.Params),
NormalizedPolicy: normalizeApprovalPolicy(capability.MinimumApprovalLevel),
ParamNames: sortedCapabilityParamNames(capability.Params),
}
return "policy:sha256:" + hashJSON(payload, 12)
}
func (p Planner) now() time.Time {
if p.Now != nil {
return p.Now().UTC()
}
return time.Now().UTC()
}
func (p Planner) ttl() time.Duration {
if p.TTL > 0 {
return p.TTL
}
return DefaultPlanTTL
}
func normalizeRequest(req unified.ActionRequest) unified.ActionRequest {
req.RequestID = strings.TrimSpace(req.RequestID)
req.ResourceID = unified.CanonicalResourceID(req.ResourceID)
req.CapabilityName = strings.TrimSpace(req.CapabilityName)
req.Reason = strings.TrimSpace(req.Reason)
req.RequestedBy = strings.TrimSpace(req.RequestedBy)
if req.Params == nil {
req.Params = map[string]any{}
}
return req
}
func validateRequest(req unified.ActionRequest) error {
if req.RequestID == "" {
return &ValidationError{Field: "requestId", Message: "request id is required"}
}
if req.ResourceID == "" {
return &ValidationError{Field: "resourceId", Message: "resource id is required"}
}
if req.CapabilityName == "" {
return &ValidationError{Field: "capabilityName", Message: "capability name is required"}
}
if req.Reason == "" {
return &ValidationError{Field: "reason", Message: "reason is required"}
}
if req.RequestedBy == "" {
return &ValidationError{Field: "requestedBy", Message: "requester is required"}
}
return nil
}
func findCapability(capabilities []unified.ResourceCapability, name string) (unified.ResourceCapability, bool) {
name = strings.TrimSpace(name)
for _, capability := range capabilities {
if strings.TrimSpace(capability.Name) == name {
return capability, true
}
}
return unified.ResourceCapability{}, false
}
func validateParams(params map[string]any, specs []unified.CapabilityParam) error {
specByName := make(map[string]unified.CapabilityParam, len(specs))
for _, spec := range specs {
name := strings.TrimSpace(spec.Name)
if name == "" {
return &ValidationError{Field: "params", Message: "capability declares an unnamed parameter"}
}
if _, exists := specByName[name]; exists {
return &ValidationError{Field: "params." + name, Message: "capability declares duplicate parameter"}
}
spec.Name = name
specByName[name] = spec
}
for name := range params {
trimmed := strings.TrimSpace(name)
if trimmed == "" || trimmed != name {
return &ValidationError{Field: "params", Message: "parameter names must be non-empty and trimmed"}
}
if _, ok := specByName[name]; !ok {
return &ValidationError{Field: "params." + name, Message: "parameter is not declared by this capability"}
}
}
for _, spec := range specByName {
value, exists := params[spec.Name]
if !exists || isEmptyParamValue(value) {
if spec.Required {
return &ValidationError{Field: "params." + spec.Name, Message: "required parameter is missing"}
}
continue
}
if err := validateParamValue(value, spec); err != nil {
return err
}
}
return nil
}
func isEmptyParamValue(value any) bool {
if value == nil {
return true
}
if s, ok := value.(string); ok {
return strings.TrimSpace(s) == ""
}
return false
}
func validateParamValue(value any, spec unified.CapabilityParam) error {
if err := validateParamType(value, spec); err != nil {
return err
}
if len(spec.Enum) > 0 {
valueString := enumString(value)
for _, candidate := range spec.Enum {
if valueString == strings.TrimSpace(candidate) {
return nil
}
}
return &ValidationError{Field: "params." + spec.Name, Message: "parameter value is outside the allowed enum"}
}
if spec.Pattern != "" {
valueString, ok := value.(string)
if !ok {
return &ValidationError{Field: "params." + spec.Name, Message: "pattern validation requires a string value"}
}
matched, err := regexp.MatchString(spec.Pattern, valueString)
if err != nil {
return &ValidationError{Field: "params." + spec.Name, Message: "capability declares an invalid pattern"}
}
if !matched {
return &ValidationError{Field: "params." + spec.Name, Message: "parameter value does not match the required pattern"}
}
}
return nil
}
func validateParamType(value any, spec unified.CapabilityParam) error {
typ := strings.ToLower(strings.TrimSpace(spec.Type))
switch typ {
case "", "any":
return nil
case "string":
if _, ok := value.(string); ok {
return nil
}
case "bool", "boolean":
if _, ok := value.(bool); ok {
return nil
}
case "int", "integer":
if isInteger(value) {
return nil
}
case "number", "float", "float64":
if isNumber(value) {
return nil
}
case "object", "map":
if isMap(value) {
return nil
}
case "array", "list":
if isSlice(value) {
return nil
}
default:
return &ValidationError{Field: "params." + spec.Name, Message: "capability declares unsupported parameter type " + typ}
}
return &ValidationError{Field: "params." + spec.Name, Message: "parameter must be " + typ}
}
func isInteger(value any) bool {
switch v := value.(type) {
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64:
return true
case float32:
return math.Trunc(float64(v)) == float64(v)
case float64:
return math.Trunc(v) == v
case json.Number:
_, err := v.Int64()
return err == nil
default:
return false
}
}
func isNumber(value any) bool {
switch value.(type) {
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64, json.Number:
return true
default:
return false
}
}
func isMap(value any) bool {
if _, ok := value.(map[string]any); ok {
return true
}
rv := reflect.ValueOf(value)
return rv.IsValid() && rv.Kind() == reflect.Map
}
func isSlice(value any) bool {
rv := reflect.ValueOf(value)
return rv.IsValid() && (rv.Kind() == reflect.Slice || rv.Kind() == reflect.Array)
}
func enumString(value any) string {
switch v := value.(type) {
case string:
return strings.TrimSpace(v)
case json.Number:
return v.String()
default:
return fmt.Sprint(value)
}
}
func normalizeApprovalPolicy(level unified.ActionApprovalLevel) unified.ActionApprovalLevel {
switch level {
case unified.ApprovalNone, unified.ApprovalDryRun, unified.ApprovalAdmin, unified.ApprovalMultiFactor:
return level
default:
return unified.ApprovalAdmin
}
}
func actionID(req unified.ActionRequest, resourceVersion string, policyVersion string) string {
payload := struct {
RequestID string `json:"requestId"`
ResourceID string `json:"resourceId"`
CapabilityName string `json:"capabilityName"`
Params map[string]any `json:"params"`
Reason string `json:"reason"`
RequestedBy string `json:"requestedBy"`
ResourceVersion string `json:"resourceVersion"`
PolicyVersion string `json:"policyVersion"`
}{
RequestID: req.RequestID,
ResourceID: req.ResourceID,
CapabilityName: req.CapabilityName,
Params: req.Params,
Reason: req.Reason,
RequestedBy: req.RequestedBy,
ResourceVersion: resourceVersion,
PolicyVersion: policyVersion,
}
return "act_" + hashJSON(payload, 16)
}
func planHash(req unified.ActionRequest, plan unified.ActionPlan) string {
payload := struct {
ActionID string `json:"actionId"`
Request unified.ActionRequest `json:"request"`
Allowed bool `json:"allowed"`
RequiresApproval bool `json:"requiresApproval"`
ApprovalPolicy unified.ActionApprovalLevel `json:"approvalPolicy"`
PredictedBlastRadius []string `json:"predictedBlastRadius"`
RollbackAvailable bool `json:"rollbackAvailable"`
ResourceVersion string `json:"resourceVersion"`
PolicyVersion string `json:"policyVersion"`
}{
ActionID: plan.ActionID,
Request: req,
Allowed: plan.Allowed,
RequiresApproval: plan.RequiresApproval,
ApprovalPolicy: plan.ApprovalPolicy,
PredictedBlastRadius: append([]string(nil), plan.PredictedBlastRadius...),
RollbackAvailable: plan.RollbackAvailable,
ResourceVersion: plan.ResourceVersion,
PolicyVersion: plan.PolicyVersion,
}
return "sha256:" + hashJSON(payload, 32)
}
func predictedBlastRadius(resource unified.Resource) []string {
seen := map[string]struct{}{}
add := func(id string) {
id = unified.CanonicalResourceID(id)
if id == "" {
return
}
seen[id] = struct{}{}
}
resourceID := unified.CanonicalResourceID(resource.ID)
add(resourceID)
for _, relationship := range unified.ResourceRelationshipsWithCanonicalParent(resource) {
if !relationship.Active {
continue
}
add(relationship.SourceID)
add(relationship.TargetID)
}
out := make([]string, 0, len(seen))
for id := range seen {
out = append(out, id)
}
sort.Strings(out)
if resourceID != "" {
for i, id := range out {
if id == resourceID {
copy(out[1:i+1], out[0:i])
out[0] = resourceID
break
}
}
}
return out
}
func planMessage(resource unified.Resource, capability unified.ResourceCapability, policy unified.ActionApprovalLevel) string {
target := displayResourceName(resource)
action := strings.TrimSpace(capability.Name)
if target == "" {
target = unified.CanonicalResourceID(resource.ID)
}
switch policy {
case unified.ApprovalDryRun:
return fmt.Sprintf("Plan created for %s on %s. Policy allows planning/dry-run only; execution is not performed by this endpoint.", action, target)
case unified.ApprovalNone:
return fmt.Sprintf("Plan created for %s on %s. Execution is not performed by this endpoint.", action, target)
default:
return fmt.Sprintf("Plan created for %s on %s. Execution requires %s approval and is not performed by this endpoint.", action, target, policy)
}
}
func buildPreflight(resource unified.Resource, capability unified.ResourceCapability, req unified.ActionRequest, actionID string, policy unified.ActionApprovalLevel, plannedAt time.Time) *unified.ActionPreflight {
resourceID := unified.CanonicalResourceID(resource.ID)
action := strings.TrimSpace(capability.Name)
description := strings.TrimSpace(capability.Description)
intendedChange := description
if intendedChange == "" {
intendedChange = fmt.Sprintf("Run %s on %s", action, resourceID)
}
safetyChecks := []string{
"Resource was resolved from the unified resource registry.",
"Capability is advertised by the resource contract.",
"This endpoint plans only; it does not approve or execute the action.",
}
switch policy {
case unified.ApprovalDryRun:
safetyChecks = append(safetyChecks, "Capability policy is dry-run-only.")
case unified.ApprovalNone:
safetyChecks = append(safetyChecks, "Capability policy allows execution without additional approval.")
default:
safetyChecks = append(safetyChecks, fmt.Sprintf("Execution requires %s approval.", policy))
}
return &unified.ActionPreflight{
Target: resourceID,
CurrentState: currentState(resource),
IntendedChange: intendedChange,
DryRunAvailable: false,
DryRunSummary: "No provider-supported dry run is advertised for this capability.",
SafetyChecks: safetyChecks,
VerificationSteps: []string{
"Refresh the resource and confirm the expected state after execution.",
"Review /api/audit/actions/" + actionID + "/events for lifecycle evidence.",
},
GeneratedAt: plannedAt,
}
}
func currentState(resource unified.Resource) string {
name := displayResourceName(resource)
status := strings.TrimSpace(string(resource.Status))
if name == "" {
name = unified.CanonicalResourceID(resource.ID)
}
if status == "" {
return name
}
return fmt.Sprintf("%s is %s", name, status)
}
func displayResourceName(resource unified.Resource) string {
if resource.Canonical != nil && strings.TrimSpace(resource.Canonical.DisplayName) != "" {
return strings.TrimSpace(resource.Canonical.DisplayName)
}
if strings.TrimSpace(resource.Name) != "" {
return strings.TrimSpace(resource.Name)
}
return unified.CanonicalResourceID(resource.ID)
}
type normalizedIdentity struct {
MachineID string `json:"machineId,omitempty"`
DMIUUID string `json:"dmiUuid,omitempty"`
Hostnames []string `json:"hostnames,omitempty"`
IPAddresses []string `json:"ipAddresses,omitempty"`
MACAddresses []string `json:"macAddresses,omitempty"`
ClusterName string `json:"clusterName,omitempty"`
}
type normalizedCapabilityForResourceHash struct {
Name string `json:"name"`
Type unified.CapabilityType `json:"type"`
Description string `json:"description"`
MinimumApprovalLevel unified.ActionApprovalLevel `json:"minimumApprovalLevel"`
Platform string `json:"platform,omitempty"`
Params []unified.CapabilityParam `json:"params,omitempty"`
}
type normalizedRelationship struct {
SourceID string `json:"sourceId"`
TargetID string `json:"targetId"`
Type unified.RelationshipType `json:"type"`
Confidence float64 `json:"confidence"`
Active bool `json:"active"`
Discoverer string `json:"discoverer"`
ObservedAt time.Time `json:"observedAt,omitempty"`
LastSeenAt time.Time `json:"lastSeenAt,omitempty"`
Metadata map[string]any `json:"metadata,omitempty"`
}
type normalizedChange struct {
ID string `json:"id"`
ResourceID string `json:"resourceId"`
Kind unified.ChangeKind `json:"kind"`
From string `json:"from,omitempty"`
To string `json:"to,omitempty"`
SourceType unified.ChangeSourceType `json:"sourceType"`
SourceAdapter unified.ChangeSourceAdapter `json:"sourceAdapter"`
Confidence unified.ChangeConfidence `json:"confidence"`
RelatedResources []string `json:"relatedResources,omitempty"`
}
type storageIncidentHashFields struct {
Severity string `json:"severity,omitempty"`
Summary string `json:"summary,omitempty"`
Category string `json:"category,omitempty"`
Priority int `json:"priority,omitempty"`
}
type sourceStatusHash struct {
Status string `json:"status"`
Error string `json:"error,omitempty"`
}
func normalizeDataSources(sources []unified.DataSource) []unified.DataSource {
out := make([]unified.DataSource, 0, len(sources))
seen := map[unified.DataSource]struct{}{}
for _, source := range sources {
normalized := unified.DataSource(strings.ToLower(strings.TrimSpace(string(source))))
if normalized == "" {
continue
}
if _, exists := seen[normalized]; exists {
continue
}
seen[normalized] = struct{}{}
out = append(out, normalized)
}
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out
}
func normalizeIdentity(identity unified.ResourceIdentity) normalizedIdentity {
return normalizedIdentity{
MachineID: strings.TrimSpace(identity.MachineID),
DMIUUID: strings.TrimSpace(identity.DMIUUID),
Hostnames: sortedTrimmedStrings(identity.Hostnames),
IPAddresses: sortedTrimmedStrings(identity.IPAddresses),
MACAddresses: sortedTrimmedStrings(identity.MACAddresses),
ClusterName: strings.TrimSpace(identity.ClusterName),
}
}
func normalizeCapabilitiesForResourceHash(capabilities []unified.ResourceCapability) []normalizedCapabilityForResourceHash {
out := make([]normalizedCapabilityForResourceHash, 0, len(capabilities))
for _, capability := range capabilities {
out = append(out, normalizedCapabilityForResourceHash{
Name: strings.TrimSpace(capability.Name),
Type: capability.Type,
Description: strings.TrimSpace(capability.Description),
MinimumApprovalLevel: capability.MinimumApprovalLevel,
Platform: strings.TrimSpace(capability.Platform),
Params: normalizeCapabilityParams(capability.Params),
})
}
sort.Slice(out, func(i, j int) bool {
if out[i].Name == out[j].Name {
return out[i].Platform < out[j].Platform
}
return out[i].Name < out[j].Name
})
return out
}
func normalizeCapabilityParams(params []unified.CapabilityParam) []unified.CapabilityParam {
out := make([]unified.CapabilityParam, 0, len(params))
for _, param := range params {
param.Name = strings.TrimSpace(param.Name)
param.Type = strings.ToLower(strings.TrimSpace(param.Type))
param.Pattern = strings.TrimSpace(param.Pattern)
param.Description = strings.TrimSpace(param.Description)
param.Enum = sortedTrimmedStrings(param.Enum)
out = append(out, param)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
func sortedCapabilityParamNames(params []unified.CapabilityParam) []string {
names := make([]string, 0, len(params))
for _, param := range params {
if name := strings.TrimSpace(param.Name); name != "" {
names = append(names, name)
}
}
sort.Strings(names)
return names
}
func normalizeRelationships(relationships []unified.ResourceRelationship) []normalizedRelationship {
out := make([]normalizedRelationship, 0, len(relationships))
for _, relationship := range relationships {
out = append(out, normalizedRelationship{
SourceID: unified.CanonicalResourceID(relationship.SourceID),
TargetID: unified.CanonicalResourceID(relationship.TargetID),
Type: relationship.Type,
Confidence: relationship.Confidence,
Active: relationship.Active,
Discoverer: strings.TrimSpace(relationship.Discoverer),
ObservedAt: relationship.ObservedAt.UTC(),
LastSeenAt: relationship.LastSeenAt.UTC(),
Metadata: relationship.Metadata,
})
}
sort.Slice(out, func(i, j int) bool {
if out[i].SourceID != out[j].SourceID {
return out[i].SourceID < out[j].SourceID
}
if out[i].TargetID != out[j].TargetID {
return out[i].TargetID < out[j].TargetID
}
return out[i].Type < out[j].Type
})
return out
}
func normalizeChanges(changes []unified.ResourceChange) []normalizedChange {
out := make([]normalizedChange, 0, len(changes))
for _, change := range changes {
out = append(out, normalizedChange{
ID: strings.TrimSpace(change.ID),
ResourceID: unified.CanonicalResourceID(change.ResourceID),
Kind: change.Kind,
From: strings.TrimSpace(change.From),
To: strings.TrimSpace(change.To),
SourceType: change.SourceType,
SourceAdapter: change.SourceAdapter,
Confidence: change.Confidence,
RelatedResources: sortedCanonicalResourceIDs(change.RelatedResources),
})
}
sort.Slice(out, func(i, j int) bool {
if out[i].ResourceID != out[j].ResourceID {
return out[i].ResourceID < out[j].ResourceID
}
if out[i].ID != out[j].ID {
return out[i].ID < out[j].ID
}
return out[i].Kind < out[j].Kind
})
return out
}
func normalizeSourceStatus(status map[unified.DataSource]unified.SourceStatus) map[unified.DataSource]sourceStatusHash {
if len(status) == 0 {
return nil
}
out := make(map[unified.DataSource]sourceStatusHash, len(status))
for source, sourceStatus := range status {
normalizedSource := unified.DataSource(strings.ToLower(strings.TrimSpace(string(source))))
if normalizedSource == "" {
continue
}
out[normalizedSource] = sourceStatusHash{
Status: strings.TrimSpace(sourceStatus.Status),
Error: strings.TrimSpace(sourceStatus.Error),
}
}
return out
}
func sortedCanonicalResourceIDs(ids []string) []string {
out := make([]string, 0, len(ids))
seen := map[string]struct{}{}
for _, id := range ids {
id = unified.CanonicalResourceID(id)
if id == "" {
continue
}
if _, exists := seen[id]; exists {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
sort.Strings(out)
return out
}
func sortedTrimmedStrings(values []string) []string {
out := make([]string, 0, len(values))
seen := map[string]struct{}{}
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, exists := seen[value]; exists {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
sort.Strings(out)
return out
}
func hashJSON(value any, bytes int) string {
payload, err := json.Marshal(value)
if err != nil {
payload = []byte(fmt.Sprintf("%#v", value))
}
sum := sha256.Sum256(payload)
if bytes <= 0 || bytes > len(sum) {
bytes = len(sum)
}
return hex.EncodeToString(sum[:bytes])
}