mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-09 16:00:59 +00:00
803 lines
26 KiB
Go
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])
|
|
}
|