diff --git a/api/datareading.go b/api/datareading.go index 54637f3c..a3ac67ac 100644 --- a/api/datareading.go +++ b/api/datareading.go @@ -3,6 +3,8 @@ package api import ( "encoding/json" "time" + + "k8s.io/apimachinery/pkg/version" ) // DataReadingsPost is the payload in the upload request. @@ -48,3 +50,23 @@ func (v GatheredResource) MarshalJSON() ([]byte, error) { return json.Marshal(data) } + +// DynamicData is the DataReading.Data returned by the k8s.DataGathererDynamic +// gatherer +type DynamicData struct { + // Items is a list of GatheredResource + Items []*GatheredResource `json:"items"` +} + +// DiscoveryData is the DataReading.Data returned by the k8s.ConfigDiscovery +// gatherer +type DiscoveryData struct { + // ClusterID is the unique ID of the Kubernetes cluster which this snapshot was taken from. + // This is sourced from the kube-system namespace UID, + // which is assumed to be stable for the lifetime of the cluster. + // - https://github.com/kubernetes/kubernetes/issues/77487#issuecomment-489786023 + ClusterID string `json:"cluster_id"` + // ServerVersion is the version information of the k8s apiserver + // See https://godoc.org/k8s.io/apimachinery/pkg/version#Info + ServerVersion *version.Info `json:"server_version"` +} diff --git a/examples/machinehub.yaml b/examples/machinehub.yaml index 94b19d48..d53cd099 100644 --- a/examples/machinehub.yaml +++ b/examples/machinehub.yaml @@ -5,8 +5,126 @@ # export ARK_SUBDOMAIN= # your CyberArk tenant subdomain # export ARK_USERNAME= # your CyberArk username # export ARK_SECRET= # your CyberArk password +# +# OPTIONAL: the URL for the CyberArk Discovery API if not using the production environment +# # export ARK_DISCOVERY_API=https://platform-discovery.integration-cyberark.cloud/api/v2 +# # go run . agent --one-shot --machine-hub -v 6 --agent-config-file ./examples/machinehub.yaml data-gatherers: - - kind: "dummy" - name: "dummy" +# Gather Kubernetes API server version information +- name: ark/discovery + kind: k8s-discovery + +# Gather Kubernetes secrets, excluding specific types +- name: ark/secrets + kind: k8s-dynamic + config: + resource-type: + version: v1 + resource: secrets + field-selectors: + - type!=kubernetes.io/service-account-token + - type!=kubernetes.io/dockercfg + - type!=kubernetes.io/dockerconfigjson + - type!=kubernetes.io/basic-auth + - type!=kubernetes.io/ssh-auth + - type!=bootstrap.kubernetes.io/token + - type!=helm.sh/release.v1 + +# Gather Kubernetes service accounts +- name: ark/serviceaccounts + kind: k8s-dynamic + config: + resource-type: + resource: serviceaccounts + version: v1 + +# Gather Kubernetes roles +- name: ark/roles + kind: k8s-dynamic + config: + resource-type: + version: v1 + group: rbac.authorization.k8s.io + resource: roles + +# Gather Kubernetes cluster roles +- name: ark/clusterroles + kind: k8s-dynamic + config: + resource-type: + version: v1 + group: rbac.authorization.k8s.io + resource: clusterroles + +# Gather Kubernetes role bindings +- name: ark/rolebindings + kind: k8s-dynamic + config: + resource-type: + version: v1 + group: rbac.authorization.k8s.io + resource: rolebindings + +# Gather Kubernetes cluster role bindings +- name: ark/clusterrolebindings + kind: k8s-dynamic + config: + resource-type: + version: v1 + group: rbac.authorization.k8s.io + resource: clusterrolebindings + +# Gather Kubernetes jobs +- name: ark/jobs + kind: k8s-dynamic + config: + resource-type: + version: v1 + group: batch + resource: jobs + +# Gather Kubernetes cron jobs +- name: ark/cronjobs + kind: k8s-dynamic + config: + resource-type: + version: v1 + group: batch + resource: cronjobs + +# Gather Kubernetes deployments +- name: ark/deployments + kind: k8s-dynamic + config: + resource-type: + version: v1 + group: apps + resource: deployments + +# Gather Kubernetes stateful sets +- name: ark/statefulsets + kind: k8s-dynamic + config: + resource-type: + version: v1 + group: apps + resource: statefulsets + +# Gather Kubernetes daemon sets +- name: ark/daemonsets + kind: k8s-dynamic + config: + resource-type: + version: v1 + group: apps + resource: daemonsets + +# Gather Kubernetes pods +- name: ark/pods + kind: k8s-dynamic + config: + resource-type: + version: v1 + resource: pods diff --git a/pkg/client/client_cyberark.go b/pkg/client/client_cyberark.go index c1522a9c..0737bbf9 100644 --- a/pkg/client/client_cyberark.go +++ b/pkg/client/client_cyberark.go @@ -5,6 +5,9 @@ import ( "fmt" "net/http" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/sets" + "github.com/jetstack/preflight/api" "github.com/jetstack/preflight/pkg/internal/cyberark" "github.com/jetstack/preflight/pkg/internal/cyberark/dataupload" @@ -36,10 +39,17 @@ func NewCyberArk(httpClient *http.Client) (*CyberArkClient, error) { } // PostDataReadingsWithOptions uploads data readings to CyberArk. +// It converts the supplied data readings into a snapshot format expected by CyberArk. // It initializes a data upload client with the configured HTTP client and credentials, // then uploads a snapshot. // The supplied Options are not used by this publisher. func (o *CyberArkClient) PostDataReadingsWithOptions(ctx context.Context, readings []*api.DataReading, _ Options) error { + var snapshot dataupload.Snapshot + if err := convertDataReadings(defaultExtractorFunctions, readings, &snapshot); err != nil { + return fmt.Errorf("while converting data readings: %s", err) + } + snapshot.AgentVersion = version.PreflightVersion + cfg, err := o.configLoader() if err != nil { return err @@ -49,14 +59,134 @@ func (o *CyberArkClient) PostDataReadingsWithOptions(ctx context.Context, readin return fmt.Errorf("while initializing data upload client: %s", err) } - err = datauploadClient.PutSnapshot(ctx, dataupload.Snapshot{ - // Temporary hard coded cluster ID. - // TODO(wallrj): The clusterID will eventually be extracted from the supplied readings. - ClusterID: "success-cluster-id", - AgentVersion: version.PreflightVersion, - }) + err = datauploadClient.PutSnapshot(ctx, snapshot) if err != nil { return fmt.Errorf("while uploading snapshot: %s", err) } return nil } + +// extractClusterIDAndServerVersionFromReading converts the opaque data from a DiscoveryData +// data reading to allow access to the Kubernetes version fields within. +func extractClusterIDAndServerVersionFromReading(reading *api.DataReading, target *dataupload.Snapshot) error { + if reading == nil { + return fmt.Errorf("programmer mistake: the DataReading must not be nil") + } + data, ok := reading.Data.(*api.DiscoveryData) + if !ok { + return fmt.Errorf( + "programmer mistake: the DataReading must have data type *api.DiscoveryData. "+ + "This DataReading (%s) has data type %T", reading.DataGatherer, reading.Data) + } + target.ClusterID = data.ClusterID + if data.ServerVersion != nil { + target.K8SVersion = data.ServerVersion.GitVersion + } + return nil +} + +// extractResourceListFromReading converts the opaque data from a DynamicData +// data reading to runtime.Object resources, to allow access to the metadata and +// other kubernetes API fields. +func extractResourceListFromReading(reading *api.DataReading, target *[]runtime.Object) error { + if reading == nil { + return fmt.Errorf("programmer mistake: the DataReading must not be nil") + } + data, ok := reading.Data.(*api.DynamicData) + if !ok { + return fmt.Errorf( + "programmer mistake: the DataReading must have data type *api.DynamicData. "+ + "This DataReading (%s) has data type %T", reading.DataGatherer, reading.Data) + } + resources := make([]runtime.Object, len(data.Items)) + for i, item := range data.Items { + if resource, ok := item.Resource.(runtime.Object); ok { + resources[i] = resource + } else { + return fmt.Errorf( + "programmer mistake: the DynamicData items must have Resource type runtime.Object. "+ + "This item (%d) has Resource type %T", i, item.Resource) + } + } + *target = resources + return nil +} + +var defaultExtractorFunctions = map[string]func(*api.DataReading, *dataupload.Snapshot) error{ + "ark/discovery": extractClusterIDAndServerVersionFromReading, + "ark/secrets": func(r *api.DataReading, s *dataupload.Snapshot) error { + return extractResourceListFromReading(r, &s.Secrets) + }, + "ark/serviceaccounts": func(r *api.DataReading, s *dataupload.Snapshot) error { + return extractResourceListFromReading(r, &s.ServiceAccounts) + }, + "ark/roles": func(r *api.DataReading, s *dataupload.Snapshot) error { + return extractResourceListFromReading(r, &s.Roles) + }, + "ark/clusterroles": func(r *api.DataReading, s *dataupload.Snapshot) error { + return extractResourceListFromReading(r, &s.ClusterRoles) + }, + "ark/rolebindings": func(r *api.DataReading, s *dataupload.Snapshot) error { + return extractResourceListFromReading(r, &s.RoleBindings) + }, + "ark/clusterrolebindings": func(r *api.DataReading, s *dataupload.Snapshot) error { + return extractResourceListFromReading(r, &s.ClusterRoleBindings) + }, + "ark/jobs": func(r *api.DataReading, s *dataupload.Snapshot) error { + return extractResourceListFromReading(r, &s.Jobs) + }, + "ark/cronjobs": func(r *api.DataReading, s *dataupload.Snapshot) error { + return extractResourceListFromReading(r, &s.CronJobs) + }, + "ark/deployments": func(r *api.DataReading, s *dataupload.Snapshot) error { + return extractResourceListFromReading(r, &s.Deployments) + }, + "ark/statefulsets": func(r *api.DataReading, s *dataupload.Snapshot) error { + return extractResourceListFromReading(r, &s.Statefulsets) + }, + "ark/daemonsets": func(r *api.DataReading, s *dataupload.Snapshot) error { + return extractResourceListFromReading(r, &s.Daemonsets) + }, + "ark/pods": func(r *api.DataReading, s *dataupload.Snapshot) error { + return extractResourceListFromReading(r, &s.Pods) + }, +} + +// convertDataReadings processes a list of DataReadings using the provided +// extractor functions to populate the fields of the target snapshot. +// It ensures that all expected data gatherers are handled and that there are +// no unhandled data gatherers. If any discrepancies are found, or if any +// extractor function returns an error, it returns an error. +// The extractorFunctions map should contain functions for each expected +// DataGatherer name, which will be called with the corresponding DataReading +// and the target snapshot to populate the relevant fields. +func convertDataReadings( + extractorFunctions map[string]func(*api.DataReading, *dataupload.Snapshot) error, + readings []*api.DataReading, + target *dataupload.Snapshot, +) error { + expectedDataGatherers := sets.KeySet(extractorFunctions) + unhandledDataGatherers := sets.New[string]() + missingDataGatherers := expectedDataGatherers.Clone() + for _, reading := range readings { + dataGathererName := reading.DataGatherer + extractFunc, found := extractorFunctions[dataGathererName] + if !found { + unhandledDataGatherers.Insert(dataGathererName) + continue + } + missingDataGatherers.Delete(dataGathererName) + // Call the extractor function to populate the relevant field in the target snapshot. + if err := extractFunc(reading, target); err != nil { + return fmt.Errorf("while extracting data reading %s: %s", dataGathererName, err) + } + } + if missingDataGatherers.Len() > 0 || unhandledDataGatherers.Len() > 0 { + return fmt.Errorf( + "unexpected data gatherers, missing: %v, unhandled: %v", + sets.List(missingDataGatherers), + sets.List(unhandledDataGatherers), + ) + } + return nil +} diff --git a/pkg/client/client_cyberark_convertdatareadings_test.go b/pkg/client/client_cyberark_convertdatareadings_test.go new file mode 100644 index 00000000..5724335d --- /dev/null +++ b/pkg/client/client_cyberark_convertdatareadings_test.go @@ -0,0 +1,311 @@ +package client + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/version" + + "github.com/jetstack/preflight/api" + "github.com/jetstack/preflight/pkg/internal/cyberark/dataupload" +) + +// TestExtractServerVersionFromReading tests the extractServerVersionFromReading function. +func TestExtractServerVersionFromReading(t *testing.T) { + type testCase struct { + name string + reading *api.DataReading + expectedSnapshot dataupload.Snapshot + expectError string + } + tests := []testCase{ + { + name: "nil reading", + expectError: `programmer mistake: the DataReading must not be nil`, + }, + { + name: "nil data", + reading: &api.DataReading{ + DataGatherer: "ark/discovery", + Data: nil, + }, + expectError: `programmer mistake: the DataReading must have data type *api.DiscoveryData. This DataReading (ark/discovery) has data type `, + }, + { + name: "wrong data type", + reading: &api.DataReading{ + DataGatherer: "ark/discovery", + Data: &api.DynamicData{}, + }, + expectError: `programmer mistake: the DataReading must have data type *api.DiscoveryData. This DataReading (ark/discovery) has data type *api.DynamicData`, + }, + { + name: "nil server version", + reading: &api.DataReading{ + DataGatherer: "ark/discovery", + Data: &api.DiscoveryData{}, + }, + expectedSnapshot: dataupload.Snapshot{}, + }, + { + name: "happy path", + reading: &api.DataReading{ + DataGatherer: "ark/discovery", + Data: &api.DiscoveryData{ + ClusterID: "success-cluster-id", + ServerVersion: &version.Info{ + GitVersion: "v1.21.0", + }, + }, + }, + expectedSnapshot: dataupload.Snapshot{ + ClusterID: "success-cluster-id", + K8SVersion: "v1.21.0", + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var snapshot dataupload.Snapshot + err := extractClusterIDAndServerVersionFromReading(test.reading, &snapshot) + if test.expectError != "" { + assert.EqualError(t, err, test.expectError) + assert.Equal(t, dataupload.Snapshot{}, snapshot) + return + } + require.NoError(t, err) + assert.Equal(t, test.expectedSnapshot, snapshot) + }) + } +} + +// TestExtractResourceListFromReading tests the extractResourceListFromReading function. +func TestExtractResourceListFromReading(t *testing.T) { + type testCase struct { + name string + reading *api.DataReading + expectedNumItems int + expectError string + } + tests := []testCase{ + { + name: "nil reading", + expectError: `programmer mistake: the DataReading must not be nil`, + }, + { + name: "nil data", + reading: &api.DataReading{ + DataGatherer: "ark/namespaces", + Data: nil, + }, + expectError: `programmer mistake: the DataReading must have data type *api.DynamicData. ` + + `This DataReading (ark/namespaces) has data type `, + }, + { + name: "wrong data type", + reading: &api.DataReading{ + DataGatherer: "ark/namespaces", + Data: &api.DiscoveryData{}, + }, + expectError: `programmer mistake: the DataReading must have data type *api.DynamicData. ` + + `This DataReading (ark/namespaces) has data type *api.DiscoveryData`, + }, + { + name: "nil items", + reading: &api.DataReading{ + DataGatherer: "ark/namespaces", + Data: &api.DynamicData{}, + }, + expectedNumItems: 0, + }, + { + name: "empty items", + reading: &api.DataReading{ + DataGatherer: "ark/namespaces", + Data: &api.DynamicData{ + Items: []*api.GatheredResource{}, + }, + }, + expectedNumItems: 0, + }, + { + name: "wrong item resource type", + reading: &api.DataReading{ + DataGatherer: "ark/namespaces", + Data: &api.DynamicData{ + Items: []*api.GatheredResource{ + { + Resource: &api.DiscoveryData{}, + }, + }, + }, + }, + expectError: `programmer mistake: the DynamicData items must have Resource type runtime.Object. ` + + `This item (0) has Resource type *api.DiscoveryData`, + }, + { + name: "happy path", + reading: &api.DataReading{ + DataGatherer: "ark/namespaces", + Data: &api.DynamicData{ + Items: []*api.GatheredResource{ + { + Resource: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "kind": "Namespace", + "metadata": map[string]interface{}{ + "name": "default", + "uid": "uid-default", + }, + }, + }, + }, + { + Resource: &unstructured.Unstructured{ + Object: map[string]interface{}{ + "kind": "Namespace", + "metadata": map[string]interface{}{ + "name": "kube-system", + "uid": "uid-kube-system", + }, + }, + }, + }, + }, + }, + }, + expectedNumItems: 2, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var resources []runtime.Object + err := extractResourceListFromReading(test.reading, &resources) + if test.expectError != "" { + assert.EqualError(t, err, test.expectError) + assert.Nil(t, resources) + return + } + require.NoError(t, err) + require.NotNil(t, resources) + assert.Len(t, resources, test.expectedNumItems) + }) + } +} + +// TestConvertDataReadings tests the convertDataReadings function. +func TestConvertDataReadings(t *testing.T) { + simpleExtractorFunctions := map[string]func(*api.DataReading, *dataupload.Snapshot) error{ + "ark/discovery": extractClusterIDAndServerVersionFromReading, + "ark/secrets": func(reading *api.DataReading, snapshot *dataupload.Snapshot) error { + return extractResourceListFromReading(reading, &snapshot.Secrets) + }, + } + simpleReadings := []*api.DataReading{ + { + DataGatherer: "ark/discovery", + Data: &api.DiscoveryData{ + ClusterID: "success-cluster-id", + ServerVersion: &version.Info{ + GitVersion: "v1.21.0", + }, + }, + }, + { + DataGatherer: "ark/secrets", + Data: &api.DynamicData{ + Items: []*api.GatheredResource{ + { + Resource: &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "app-1", + Namespace: "team-1", + }, + }, + }, + }, + }, + }, + } + + type testCase struct { + name string + extractorFunctions map[string]func(*api.DataReading, *dataupload.Snapshot) error + readings []*api.DataReading + expectedSnapshot dataupload.Snapshot + expectError string + } + tests := []testCase{ + { + name: "no extractor functions", + readings: simpleReadings, + extractorFunctions: map[string]func(*api.DataReading, *dataupload.Snapshot) error{}, + expectError: `unexpected data gatherers, missing: [], unhandled: [ark/discovery ark/secrets]`, + }, + { + name: "nil extractor functions", + readings: simpleReadings, + extractorFunctions: nil, + expectError: `unexpected data gatherers, missing: [], unhandled: [ark/discovery ark/secrets]`, + }, + { + name: "empty readings", + extractorFunctions: simpleExtractorFunctions, + readings: []*api.DataReading{}, + expectError: `unexpected data gatherers, missing: [ark/discovery ark/secrets], unhandled: []`, + }, + { + name: "nil readings", + extractorFunctions: simpleExtractorFunctions, + readings: nil, + expectError: `unexpected data gatherers, missing: [ark/discovery ark/secrets], unhandled: []`, + }, + { + name: "extractor function error", + extractorFunctions: simpleExtractorFunctions, + readings: []*api.DataReading{ + { + DataGatherer: "ark/discovery", + Data: &api.DynamicData{}, + }, + }, + expectError: `while extracting data reading ark/discovery: programmer mistake: the DataReading must have data type *api.DiscoveryData. This DataReading (ark/discovery) has data type *api.DynamicData`, + }, + { + name: "happy path", + extractorFunctions: simpleExtractorFunctions, + readings: simpleReadings, + expectedSnapshot: dataupload.Snapshot{ + ClusterID: "success-cluster-id", + K8SVersion: "v1.21.0", + Secrets: []runtime.Object{ + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "app-1", + Namespace: "team-1", + }, + }, + }, + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var snapshot dataupload.Snapshot + err := convertDataReadings(test.extractorFunctions, test.readings, &snapshot) + if test.expectError != "" { + assert.EqualError(t, err, test.expectError) + assert.Equal(t, dataupload.Snapshot{}, snapshot) + return + } + require.NoError(t, err) + assert.Equal(t, test.expectedSnapshot, snapshot) + }) + } + +} diff --git a/pkg/client/client_cyberark_test.go b/pkg/client/client_cyberark_test.go index 700e6cca..77c5e983 100644 --- a/pkg/client/client_cyberark_test.go +++ b/pkg/client/client_cyberark_test.go @@ -7,6 +7,7 @@ import ( "github.com/jetstack/venafi-connection-lib/http_client" "github.com/stretchr/testify/require" + k8sversion "k8s.io/apimachinery/pkg/version" "k8s.io/client-go/transport" "k8s.io/klog/v2" "k8s.io/klog/v2/ktesting" @@ -38,7 +39,7 @@ func TestCyberArkClient_PostDataReadingsWithOptions_MockAPI(t *testing.T) { c, err := client.NewCyberArk(httpClient) require.NoError(t, err) - var readings []*api.DataReading + readings := fakeReadings() err = c.PostDataReadingsWithOptions(ctx, readings, client.Options{}) require.NoError(t, err) }) @@ -67,8 +68,52 @@ func TestCyberArkClient_PostDataReadingsWithOptions_RealAPI(t *testing.T) { } require.NoError(t, err) } - var readings []*api.DataReading + readings := fakeReadings() err = c.PostDataReadingsWithOptions(ctx, readings, client.Options{}) require.NoError(t, err) }) } + +// defaultDynamicDatagathererNames is the list of dynamic datagatherers that +// are included in the defaultExtractorFunctions map in client_cyberark.go. +// This is used by fakeReadings to generate empty readings for all the +// dynamic datagatherers. +var defaultDynamicDatagathererNames = []string{ + "ark/secrets", + "ark/serviceaccounts", + "ark/roles", + "ark/clusterroles", + "ark/rolebindings", + "ark/clusterrolebindings", + "ark/jobs", + "ark/cronjobs", + "ark/deployments", + "ark/statefulsets", + "ark/daemonsets", + "ark/pods", +} + +// fakeReadings returns a set of fake readings that includes a discovery reading +// and empty readings for all the default dynamic datagatherers. +func fakeReadings() []*api.DataReading { + readings := make([]*api.DataReading, len(defaultDynamicDatagathererNames)) + + for i, name := range defaultDynamicDatagathererNames { + readings[i] = &api.DataReading{ + DataGatherer: name, + Data: &api.DynamicData{}, + } + } + + return append([]*api.DataReading{ + { + DataGatherer: "ark/discovery", + Data: &api.DiscoveryData{ + ClusterID: "success-cluster-id", + ServerVersion: &k8sversion.Info{ + GitVersion: "v1.21.0", + }, + }, + }, + }, readings...) +} diff --git a/pkg/clusteruid/clusteruid.go b/pkg/clusteruid/clusteruid.go deleted file mode 100644 index 2a5327f2..00000000 --- a/pkg/clusteruid/clusteruid.go +++ /dev/null @@ -1,45 +0,0 @@ -package clusteruid - -import ( - "context" - - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/client-go/kubernetes" -) - -// clusterUIDKey is the context key for storing the cluster UID -type clusterUIDKey struct{} - -// GetClusterUID retrieves the UID of the kube-system namespace using the given Kubernetes clientset. -// This UID can be used as a unique identifier for the Kubernetes cluster. -// The UID is stored in the given context for later retrieval; use ClusterUIDFromContext to get it. -func GetClusterUID(ctx context.Context, clientset kubernetes.Interface) (context.Context, error) { - namespace, err := clientset.CoreV1().Namespaces().Get(ctx, "kube-system", metav1.GetOptions{}) - if err != nil { - return ctx, err - } - - ctx = withClusterUID(ctx, string(namespace.ObjectMeta.UID)) - return ctx, nil -} - -// ClusterUIDFromContext retrieves the cluster UID from the context. -// Panics if the value is not found or if the value is not a string. -func ClusterUIDFromContext(ctx context.Context) string { - value := ctx.Value(clusterUIDKey{}) - if value == nil { - panic("cluster UID not found in context") - } - - uid, ok := value.(string) - if !ok { - panic("cluster UID in context is not a string") - } - - return uid -} - -// withClusterUID adds the given cluster UID to the context -func withClusterUID(ctx context.Context, clusterUID string) context.Context { - return context.WithValue(ctx, clusterUIDKey{}, clusterUID) -} diff --git a/pkg/clusteruid/clusteruid_test.go b/pkg/clusteruid/clusteruid_test.go deleted file mode 100644 index 1d1cacae..00000000 --- a/pkg/clusteruid/clusteruid_test.go +++ /dev/null @@ -1,39 +0,0 @@ -package clusteruid - -import ( - "testing" - - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/types" - "k8s.io/client-go/kubernetes/fake" -) - -func TestGetClusterUID(t *testing.T) { - client := fake.NewSimpleClientset() - - mockUID := "12345678-1234-5678-1234-567812345678" - - kubeSystemNS := &corev1.Namespace{ - ObjectMeta: metav1.ObjectMeta{ - Name: "kube-system", - UID: types.UID(mockUID), - }, - } - - _, err := client.CoreV1().Namespaces().Create(t.Context(), kubeSystemNS, metav1.CreateOptions{}) - if err != nil { - t.Fatalf("failed to create kube-system namespace with fake client: %v", err) - } - - ctx, err := GetClusterUID(t.Context(), client) - if err != nil { - t.Fatalf("expected no error, got %v", err) - } - - uid := ClusterUIDFromContext(ctx) - - if uid != mockUID { - t.Fatalf("expected to get uid=%v, but got uid=%v", mockUID, uid) - } -} diff --git a/pkg/datagatherer/k8s/discovery.go b/pkg/datagatherer/k8s/discovery.go index 586622d6..cc44d4e5 100644 --- a/pkg/datagatherer/k8s/discovery.go +++ b/pkg/datagatherer/k8s/discovery.go @@ -4,8 +4,10 @@ import ( "context" "fmt" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/discovery" + "github.com/jetstack/preflight/api" "github.com/jetstack/preflight/pkg/datagatherer" ) @@ -32,19 +34,34 @@ func (c *ConfigDiscovery) UnmarshalYAML(unmarshal func(interface{}) error) error // NewDataGatherer constructs a new instance of the generic K8s data-gatherer for the provided // GroupVersionResource. +// It gets the UID of the 'kube-system' namespace to use as the cluster ID, once at startup. +// The UID is assumed to be stable for the lifetime of the cluster. +// - https://github.com/kubernetes/kubernetes/issues/77487#issuecomment-489786023 func (c *ConfigDiscovery) NewDataGatherer(ctx context.Context) (datagatherer.DataGatherer, error) { cl, err := NewDiscoveryClient(c.KubeConfigPath) if err != nil { return nil, err } - - return &DataGathererDiscovery{cl: cl}, nil + cs, err := NewClientSet(c.KubeConfigPath) + if err != nil { + return nil, fmt.Errorf("while creating new clientset: %s", err) + } + kubesystemNS, err := cs.CoreV1().Namespaces().Get(ctx, "kube-system", metav1.GetOptions{}) + if err != nil { + return nil, fmt.Errorf("while getting the kube-system namespace: %s", err) + } + return &DataGathererDiscovery{ + cl: cl, + clusterID: string(kubesystemNS.UID), + }, nil } // DataGathererDiscovery stores the config for a k8s-discovery datagatherer type DataGathererDiscovery struct { // The 'discovery' client used for fetching data. cl *discovery.DiscoveryClient + // The cluster ID, derived from the UID of the 'kube-system' namespace. + clusterID string } func (g *DataGathererDiscovery) Run(ctx context.Context) error { @@ -63,11 +80,9 @@ func (g *DataGathererDiscovery) Fetch() (interface{}, int, error) { if err != nil { return nil, -1, fmt.Errorf("failed to get server version: %v", err) } - - response := map[string]interface{}{ - // data has type Info: https://godoc.org/k8s.io/apimachinery/pkg/version#Info - "server_version": data, + response := &api.DiscoveryData{ + ClusterID: g.clusterID, + ServerVersion: data, } - - return response, len(response), nil + return response, 1, nil } diff --git a/pkg/datagatherer/k8s/dynamic.go b/pkg/datagatherer/k8s/dynamic.go index 50b6c0a4..7cb131b5 100644 --- a/pkg/datagatherer/k8s/dynamic.go +++ b/pkg/datagatherer/k8s/dynamic.go @@ -314,7 +314,6 @@ func (g *DataGathererDynamic) Fetch() (interface{}, int, error) { return nil, -1, fmt.Errorf("resource type must be specified") } - var list = map[string]interface{}{} var items = []*api.GatheredResource{} fetchNamespaces := g.namespaces @@ -344,10 +343,9 @@ func (g *DataGathererDynamic) Fetch() (interface{}, int, error) { return nil, -1, err } - // add gathered resources to items - list["items"] = items - - return list, len(items), nil + return &api.DynamicData{ + Items: items, + }, len(items), nil } // redactList removes sensitive and superfluous data from the supplied resource list. diff --git a/pkg/datagatherer/k8s/dynamic_test.go b/pkg/datagatherer/k8s/dynamic_test.go index 072c4c1c..525c8892 100644 --- a/pkg/datagatherer/k8s/dynamic_test.go +++ b/pkg/datagatherer/k8s/dynamic_test.go @@ -730,15 +730,12 @@ func TestDynamicGatherer_Fetch(t *testing.T) { } if tc.expected != nil { - items, ok := res.(map[string]interface{}) + data, ok := res.(*api.DynamicData) if !ok { - t.Errorf("expected result be an map[string]interface{} but wasn't") + t.Errorf("expected result be *api.DynamicData but wasn't") } - list, ok := items["items"].([]*api.GatheredResource) - if !ok { - t.Errorf("expected result be an []*api.GatheredResource but wasn't") - } + list := data.Items // sorting list of results by name sortGatheredResources(list) // sorting list of expected results by name @@ -1045,10 +1042,9 @@ func TestDynamicGathererNativeResources_Fetch(t *testing.T) { } if tc.expected != nil { - res, ok := rawRes.(map[string]interface{}) - require.Truef(t, ok, "expected result be an map[string]interface{} but wasn't") - actual := res["items"].([]*api.GatheredResource) - require.Truef(t, ok, "expected result be an []*api.GatheredResource but wasn't") + res, ok := rawRes.(*api.DynamicData) + require.Truef(t, ok, "expected result be an *api.DynamicData but wasn't") + actual := res.Items // sorting list of results by name sortGatheredResources(actual) diff --git a/pkg/internal/cyberark/client_test.go b/pkg/internal/cyberark/client_test.go index c8fa98ab..a2c31fe4 100644 --- a/pkg/internal/cyberark/client_test.go +++ b/pkg/internal/cyberark/client_test.go @@ -37,7 +37,8 @@ func TestCyberArkClient_PutSnapshot_MockAPI(t *testing.T) { require.NoError(t, err) err = cl.PutSnapshot(ctx, dataupload.Snapshot{ - ClusterID: "success-cluster-id", + ClusterID: "success-cluster-id", + AgentVersion: version.PreflightVersion, }) require.NoError(t, err) } @@ -74,7 +75,8 @@ func TestCyberArkClient_PutSnapshot_RealAPI(t *testing.T) { require.NoError(t, err) err = cl.PutSnapshot(ctx, dataupload.Snapshot{ - ClusterID: "bb068932-c80d-460d-88df-34bc7f3f3297", + ClusterID: "bb068932-c80d-460d-88df-34bc7f3f3297", + AgentVersion: version.PreflightVersion, }) require.NoError(t, err) } diff --git a/pkg/internal/cyberark/dataupload/dataupload.go b/pkg/internal/cyberark/dataupload/dataupload.go index 9d9444f8..0f6b95ef 100644 --- a/pkg/internal/cyberark/dataupload/dataupload.go +++ b/pkg/internal/cyberark/dataupload/dataupload.go @@ -12,7 +12,7 @@ import ( "net/http" "net/url" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" "github.com/jetstack/preflight/pkg/version" ) @@ -54,29 +54,29 @@ type Snapshot struct { K8SVersion string `json:"k8s_version"` // Secrets is a list of Secret resources in the cluster. Not all Secret // types are included and only a subset of the Secret data is included. - Secrets []*unstructured.Unstructured `json:"secrets"` + Secrets []runtime.Object `json:"secrets"` // ServiceAccounts is a list of ServiceAccount resources in the cluster. - ServiceAccounts []*unstructured.Unstructured `json:"serviceaccounts"` + ServiceAccounts []runtime.Object `json:"serviceaccounts"` // Roles is a list of Role resources in the cluster. - Roles []*unstructured.Unstructured `json:"roles"` + Roles []runtime.Object `json:"roles"` // ClusterRoles is a list of ClusterRole resources in the cluster. - ClusterRoles []*unstructured.Unstructured `json:"clusterroles"` + ClusterRoles []runtime.Object `json:"clusterroles"` // RoleBindings is a list of RoleBinding resources in the cluster. - RoleBindings []*unstructured.Unstructured `json:"rolebindings"` + RoleBindings []runtime.Object `json:"rolebindings"` // ClusterRoleBindings is a list of ClusterRoleBinding resources in the cluster. - ClusterRoleBindings []*unstructured.Unstructured `json:"clusterrolebindings"` + ClusterRoleBindings []runtime.Object `json:"clusterrolebindings"` // Jobs is a list of Job resources in the cluster. - Jobs []*unstructured.Unstructured `json:"jobs"` + Jobs []runtime.Object `json:"jobs"` // CronJobs is a list of CronJob resources in the cluster. - CronJobs []*unstructured.Unstructured `json:"cronjobs"` + CronJobs []runtime.Object `json:"cronjobs"` // Deployments is a list of Deployment resources in the cluster. - Deployments []*unstructured.Unstructured `json:"deployments"` + Deployments []runtime.Object `json:"deployments"` // Statefulsets is a list of StatefulSet resources in the cluster. - Statefulsets []*unstructured.Unstructured `json:"statefulsets"` + Statefulsets []runtime.Object `json:"statefulsets"` // Daemonsets is a list of DaemonSet resources in the cluster. - Daemonsets []*unstructured.Unstructured `json:"daemonsets"` + Daemonsets []runtime.Object `json:"daemonsets"` // Pods is a list of Pod resources in the cluster. - Pods []*unstructured.Unstructured `json:"pods"` + Pods []runtime.Object `json:"pods"` } // PutSnapshot PUTs the supplied snapshot to an [AWS presigned URL] which it obtains via the CyberArk inventory API. diff --git a/pkg/internal/cyberark/dataupload/dataupload_test.go b/pkg/internal/cyberark/dataupload/dataupload_test.go index 4c278e20..bdedd4f5 100644 --- a/pkg/internal/cyberark/dataupload/dataupload_test.go +++ b/pkg/internal/cyberark/dataupload/dataupload_test.go @@ -10,6 +10,7 @@ import ( "k8s.io/klog/v2/ktesting" "github.com/jetstack/preflight/pkg/internal/cyberark/dataupload" + "github.com/jetstack/preflight/pkg/version" _ "k8s.io/klog/v2/ktesting/init" ) @@ -35,7 +36,7 @@ func TestCyberArkClient_PutSnapshot_MockAPI(t *testing.T) { name: "successful upload", snapshot: dataupload.Snapshot{ ClusterID: "success-cluster-id", - AgentVersion: "test-version", + AgentVersion: version.PreflightVersion, }, authenticate: setToken("success-token"), requireFn: func(t *testing.T, err error) { diff --git a/pkg/internal/cyberark/dataupload/mock.go b/pkg/internal/cyberark/dataupload/mock.go index 55054388..992c8385 100644 --- a/pkg/internal/cyberark/dataupload/mock.go +++ b/pkg/internal/cyberark/dataupload/mock.go @@ -11,6 +11,7 @@ import ( "net/http/httptest" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "k8s.io/client-go/transport" @@ -184,6 +185,8 @@ func (mds *mockDataUploadServer) handlePresignedUpload(w http.ResponseWriter, r d.DisallowUnknownFields() err = d.Decode(&snapshot) require.NoError(mds.t, err) + assert.Equal(mds.t, successClusterID, snapshot.ClusterID) + assert.Equal(mds.t, version.PreflightVersion, snapshot.AgentVersion) // AWS S3 responds with an empty body if the PUT succeeds w.WriteHeader(http.StatusOK)