diff --git a/pkg/apimachinery/utils/manager.go b/pkg/apimachinery/utils/manager.go new file mode 100644 index 00000000000..76add8722c7 --- /dev/null +++ b/pkg/apimachinery/utils/manager.go @@ -0,0 +1,67 @@ +package utils + +import "time" + +// ManagerProperties is used to identify the manager of the resource. +type ManagerProperties struct { + // The kind of manager, which is responsible for managing the resource. + // Examples include "git", "terraform", "kubectl", etc. + Kind ManagerKind + + // The identity of the manager, which refers to a specific instance of the manager. + // The format & the value depends on the manager kind. + Identity string + + // AllowsEdits indicates whether the manager allows edits to the resource. + // If set to true, it means that other requesters can edit the resource. + AllowsEdits bool + + // Suspended indicates whether the manager is suspended. + // If set to true, then the manager skip updates to the resource. + Suspended bool +} + +// ManagerKind is the type of manager, which is responsible for managing the resource. +// It can be a user or a tool or a generic API client. +type ManagerKind string + +// Known values for ManagerKind. +const ( + ManagerKindUnknown ManagerKind = "" + ManagerKindRepo ManagerKind = "repo" + ManagerKindTerraform ManagerKind = "terraform" + ManagerKindKubectl ManagerKind = "kubectl" +) + +// ParseManagerKindString parses a string into a ManagerKind. +// It returns the ManagerKind and a boolean indicating whether the string was a valid ManagerKind. +// For unknown values, it returns ManagerKindUnknown and false. +func ParseManagerKindString(v string) ManagerKind { + switch v { + case string(ManagerKindRepo): + return ManagerKindRepo + case string(ManagerKindTerraform): + return ManagerKindTerraform + case string(ManagerKindKubectl): + return ManagerKindKubectl + default: + return ManagerKindUnknown + } +} + +// SourceProperties is used to identify the source of a provisioned resource. +// It is used by managers for reconciling data from a source to Grafana. +// Not all managers use these properties, some (like Terraform) don't have a concept of a source. +type SourceProperties struct { + // The path to the source of the resource. + // Can be a file path, a URL, etc. + Path string + + // The checksum of the source of the resource. + // An example could be a git commit hash. + Checksum string + + // The timestamp of the source of the resource. + // An example could be the file modification time. + Timestamp time.Time +} diff --git a/pkg/apimachinery/utils/meta.go b/pkg/apimachinery/utils/meta.go index 5b2978533bb..116fc4f37d9 100644 --- a/pkg/apimachinery/utils/meta.go +++ b/pkg/apimachinery/utils/meta.go @@ -46,6 +46,19 @@ const AnnoKeyRepoPath = "grafana.app/repoPath" const AnnoKeyRepoHash = "grafana.app/repoHash" const AnnoKeyRepoTimestamp = "grafana.app/repoTimestamp" +// Annotations used to store manager properties + +const AnnoKeyManagerKind = "grafana.app/managedBy" +const AnnoKeyManagerIdentity = "grafana.app/managerId" +const AnnoKeyManagerAllowsEdits = "grafana.app/managerAllowsEdits" +const AnnoKeyManagerSuspended = "grafana.app/managerSuspended" + +// Annotations used to store source properties + +const AnnoKeySourcePath = "grafana.app/sourcePath" +const AnnoKeySourceHash = "grafana.app/sourceHash" +const AnnoKeySourceTimestamp = "grafana.app/sourceTimestamp" + // LabelKeyDeprecatedInternalID gives the deprecated internal ID of a resource // Deprecated: will be removed in grafana 13 const LabelKeyDeprecatedInternalID = "grafana.app/deprecatedInternalID" @@ -138,6 +151,22 @@ type GrafanaMetaAccessor interface { // * title // and return an empty string if nothing was found FindTitle(defaultTitle string) string + + // GetManagerProperties returns the identity of the tool, + // which is responsible for managing the resource. + // + // If the identity is not known, the second return value will be false. + GetManagerProperties() (ManagerProperties, bool) + + // SetManagerProperties sets the identity of the tool, + // which is responsible for managing the resource. + SetManagerProperties(ManagerProperties) + + // GetSourceProperties returns the source properties of the resource. + GetSourceProperties() (SourceProperties, bool) + + // SetSourceProperties sets the source properties of the resource. + SetSourceProperties(SourceProperties) } var _ GrafanaMetaAccessor = (*grafanaMetaAccessor)(nil) @@ -718,6 +747,107 @@ func (m *grafanaMetaAccessor) FindTitle(defaultTitle string) string { return defaultTitle } +func (m *grafanaMetaAccessor) GetManagerProperties() (ManagerProperties, bool) { + res := ManagerProperties{ + Identity: "", + Kind: ManagerKindUnknown, + AllowsEdits: true, + Suspended: false, + } + + annot := m.obj.GetAnnotations() + + id, ok := annot[AnnoKeyManagerIdentity] + if !ok || id == "" { + // If the identity is not set, we should ignore the other annotations and return the default values. + // + // This is to prevent inadvertently marking resources as managed, + // since that can potentially block updates from other sources. + return res, false + } + res.Identity = id + + if v, ok := annot[AnnoKeyManagerKind]; ok { + res.Kind = ParseManagerKindString(v) + } + + if v, ok := annot[AnnoKeyManagerAllowsEdits]; ok { + res.AllowsEdits = v == "true" + } + + if v, ok := annot[AnnoKeyManagerSuspended]; ok { + res.Suspended = v == "true" + } + + return res, true +} + +func (m *grafanaMetaAccessor) SetManagerProperties(v ManagerProperties) { + annot := m.obj.GetAnnotations() + if annot == nil { + annot = make(map[string]string, 4) + } + + annot[AnnoKeyManagerIdentity] = v.Identity + annot[AnnoKeyManagerKind] = string(v.Kind) + annot[AnnoKeyManagerAllowsEdits] = strconv.FormatBool(v.AllowsEdits) + annot[AnnoKeyManagerSuspended] = strconv.FormatBool(v.Suspended) + + m.obj.SetAnnotations(annot) +} + +func (m *grafanaMetaAccessor) GetSourceProperties() (SourceProperties, bool) { + var ( + res SourceProperties + found bool + ) + + annot := m.obj.GetAnnotations() + if annot == nil { + return res, false + } + + if path, ok := annot[AnnoKeySourcePath]; ok && path != "" { + res.Path = path + found = true + } + + if hash, ok := annot[AnnoKeySourceHash]; ok && hash != "" { + res.Checksum = hash + found = true + } + + if timestamp, ok := annot[AnnoKeySourceTimestamp]; ok && timestamp != "" { + if t, err := time.Parse(time.RFC3339, timestamp); err == nil { + res.Timestamp = t + found = true + } + } + + return res, found +} + +func (m *grafanaMetaAccessor) SetSourceProperties(v SourceProperties) { + annot := m.obj.GetAnnotations() + if annot == nil { + annot = make(map[string]string, 3) + } + + if v.Path != "" { + annot[AnnoKeySourcePath] = v.Path + } + + if v.Checksum != "" { + annot[AnnoKeySourceHash] = v.Checksum + } + + if !v.Timestamp.IsZero() { + annot[AnnoKeySourceTimestamp] = v.Timestamp.Format(time.RFC3339) + } + + m.obj.SetAnnotations(annot) +} + type BlobInfo struct { UID string `json:"uid"` Size int64 `json:"size,omitempty"` diff --git a/pkg/apimachinery/utils/meta_test.go b/pkg/apimachinery/utils/meta_test.go index 12d12cc06b5..1796125048f 100644 --- a/pkg/apimachinery/utils/meta_test.go +++ b/pkg/apimachinery/utils/meta_test.go @@ -3,6 +3,7 @@ package utils_test import ( "encoding/json" "testing" + "time" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -433,6 +434,135 @@ func TestMetaAccessor(t *testing.T) { require.Equal(t, obj2.Spec, spec) require.NoError(t, err) }) + + t.Run("ManagerProperties", func(t *testing.T) { + tests := []struct { + name string + setProperties *utils.ManagerProperties + wantProperties utils.ManagerProperties + wantOK bool + }{ + { + name: "get default values", + wantProperties: utils.ManagerProperties{ + Identity: "", + Kind: utils.ManagerKindUnknown, + AllowsEdits: true, + Suspended: false, + }, + wantOK: false, + }, + { + name: "set and get valid values", + setProperties: &utils.ManagerProperties{ + Identity: "identity", + Kind: utils.ManagerKindTerraform, + AllowsEdits: false, + Suspended: false, + }, + wantProperties: utils.ManagerProperties{ + Identity: "identity", + Kind: utils.ManagerKindTerraform, + AllowsEdits: false, + Suspended: false, + }, + wantOK: true, + }, + { + name: "set empty identity returns default values", + setProperties: &utils.ManagerProperties{ + Identity: "", + Kind: utils.ManagerKindRepo, + AllowsEdits: false, + Suspended: false, + }, + wantProperties: utils.ManagerProperties{ + Identity: "", + Kind: utils.ManagerKindUnknown, + AllowsEdits: true, + Suspended: false, + }, + wantOK: false, + }, + { + name: "invalid kind falls back to generic kind", + setProperties: &utils.ManagerProperties{ + Identity: "identity", + Kind: utils.ManagerKind("invalid"), + AllowsEdits: false, + Suspended: true, + }, + wantProperties: utils.ManagerProperties{ + Identity: "identity", + Kind: utils.ManagerKindUnknown, + AllowsEdits: false, + Suspended: true, + }, + wantOK: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res := &TestResource2{} + meta, err := utils.MetaAccessor(res) + require.NoError(t, err) + + if tt.setProperties != nil { + meta.SetManagerProperties(*tt.setProperties) + } + + mp, ok := meta.GetManagerProperties() + require.Equal(t, tt.wantOK, ok) + require.Equal(t, tt.wantProperties, mp) + }) + } + }) + + t.Run("SourceProperties", func(t *testing.T) { + tests := []struct { + name string + setProperties *utils.SourceProperties + wantProperties utils.SourceProperties + wantOK bool + }{ + { + name: "get default values", + wantProperties: utils.SourceProperties{}, + wantOK: false, + }, + { + name: "set and get valid values", + setProperties: &utils.SourceProperties{ + Path: "path", + Checksum: "hash", + Timestamp: time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC), + }, + wantProperties: utils.SourceProperties{ + Path: "path", + Checksum: "hash", + Timestamp: time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC), + }, + wantOK: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + res := &TestResource2{} + meta, err := utils.MetaAccessor(res) + require.NoError(t, err) + + if tt.setProperties != nil { + meta.SetSourceProperties(*tt.setProperties) + } + + sp, ok := meta.GetSourceProperties() + require.Equal(t, tt.wantProperties, sp) + require.Equal(t, tt.wantOK, ok) + }) + } + }) } func asJSON(v any, pretty bool) string {