From b927d6813ba822f4d9bcfd7a469eccb0a5b54c35 Mon Sep 17 00:00:00 2001 From: Calvin Lee Date: Thu, 27 Mar 2025 17:30:50 -0400 Subject: [PATCH] LOG-6790: Update cw forwarding to use credential file and profiles --- Makefile | 2 +- ...cluster-logging.clusterserviceversion.yaml | 6 +- config/manager/manager.yaml | 2 +- .../outputs/cloudwatch-sts-forwarding.adoc | 164 +++++++++++++++ internal/collector/cloudwatch.go | 57 ----- .../cloudwatch/cloudwatch.credentials.tmpl | 7 + internal/collector/cloudwatch/cloudwatch.go | 92 ++++++++ .../collector/cloudwatch/cloudwatch_test.go | 198 ++++++++++++++++++ .../cloudwatch/cw_multiple_credentials | 15 ++ .../collector/cloudwatch/cw_single_credential | 5 + internal/collector/cloudwatch/suite_test.go | 19 ++ internal/collector/collector.go | 1 - internal/collector/collector_test.go | 26 --- internal/constants/constants.go | 5 +- .../controller/observability/collector.go | 12 ++ internal/factory/resource_names.go | 4 + internal/generator/framework/options.go | 1 + internal/generator/vector/conf/conf.go | 4 +- .../vector/output/cloudwatch/auth.go | 3 + .../vector/output/cloudwatch/cloudwatch.go | 14 +- .../output/cloudwatch/cloudwatch_test.go | 16 ++ .../cw_groupname_with_aws_credentials.toml | 2 + .../observability/outputs/cloudwatch.go | 19 +- .../observability/outputs/cloudwatch_test.go | 31 +-- .../observability/outputs/validate.go | 3 +- .../observability/outputs/validate_test.go | 8 +- olm_deploy/scripts/env.sh | 2 +- 27 files changed, 584 insertions(+), 134 deletions(-) create mode 100644 docs/features/logforwarding/outputs/cloudwatch-sts-forwarding.adoc delete mode 100644 internal/collector/cloudwatch.go create mode 100644 internal/collector/cloudwatch/cloudwatch.credentials.tmpl create mode 100644 internal/collector/cloudwatch/cloudwatch.go create mode 100644 internal/collector/cloudwatch/cloudwatch_test.go create mode 100644 internal/collector/cloudwatch/cw_multiple_credentials create mode 100644 internal/collector/cloudwatch/cw_single_credential create mode 100644 internal/collector/cloudwatch/suite_test.go diff --git a/Makefile b/Makefile index b1226aeaa5..f89cedc7f5 100644 --- a/Makefile +++ b/Makefile @@ -25,7 +25,7 @@ export LOGGING_VERSION?=6.3 export VERSION=$(LOGGING_VERSION).0 export NAMESPACE?=openshift-logging -IMAGE_LOGGING_VECTOR?=quay.io/openshift-logging/vector:6.1 +IMAGE_LOGGING_VECTOR?=quay.io/openshift-logging/vector:v0.37.1 IMAGE_LOGFILEMETRICEXPORTER?=quay.io/openshift-logging/log-file-metric-exporter:6.1 IMAGE_LOGGING_EVENTROUTER?=quay.io/openshift-logging/eventrouter:0.3 diff --git a/bundle/manifests/cluster-logging.clusterserviceversion.yaml b/bundle/manifests/cluster-logging.clusterserviceversion.yaml index 643dfd2bbb..8f268cdc2d 100644 --- a/bundle/manifests/cluster-logging.clusterserviceversion.yaml +++ b/bundle/manifests/cluster-logging.clusterserviceversion.yaml @@ -82,7 +82,7 @@ metadata: categories: OpenShift Optional, Logging & Tracing certified: "false" containerImage: quay.io/openshift-logging/cluster-logging-operator:latest - createdAt: "2025-04-07T20:20:21Z" + createdAt: "2025-04-08T15:35:39Z" description: The Red Hat OpenShift Logging Operator for OCP provides a means for configuring and managing log collection and forwarding. features.operators.openshift.io/cnf: "false" @@ -2056,7 +2056,7 @@ spec: - name: OPERATOR_NAME value: cluster-logging-operator - name: RELATED_IMAGE_VECTOR - value: quay.io/openshift-logging/vector:6.1 + value: quay.io/openshift-logging/vector:v0.37.1 - name: RELATED_IMAGE_LOG_FILE_METRIC_EXPORTER value: quay.io/openshift-logging/log-file-metric-exporter:6.1 image: quay.io/openshift-logging/cluster-logging-operator:latest @@ -2103,7 +2103,7 @@ spec: provider: name: Red Hat relatedImages: - - image: quay.io/openshift-logging/vector:6.1 + - image: quay.io/openshift-logging/vector:v0.37.1 name: vector - image: quay.io/openshift-logging/log-file-metric-exporter:6.1 name: log-file-metric-exporter diff --git a/config/manager/manager.yaml b/config/manager/manager.yaml index f03bf439c8..927290b6b7 100644 --- a/config/manager/manager.yaml +++ b/config/manager/manager.yaml @@ -48,6 +48,6 @@ spec: - name: OPERATOR_NAME value: "cluster-logging-operator" - name: RELATED_IMAGE_VECTOR - value: quay.io/openshift-logging/vector:6.1 + value: quay.io/openshift-logging/vector:v0.37.1 - name: RELATED_IMAGE_LOG_FILE_METRIC_EXPORTER value: quay.io/openshift-logging/log-file-metric-exporter:6.1 diff --git a/docs/features/logforwarding/outputs/cloudwatch-sts-forwarding.adoc b/docs/features/logforwarding/outputs/cloudwatch-sts-forwarding.adoc new file mode 100644 index 0000000000..8537e8cc06 --- /dev/null +++ b/docs/features/logforwarding/outputs/cloudwatch-sts-forwarding.adoc @@ -0,0 +1,164 @@ += Forwarding to Amazon Cloudwatch using Web Identities From an STS enabled Cluster + +This guide provides a workflow for forwarding to Amazon Cloudwatch in an <> enabled cluster. + +These steps assume that there is a <> enabled openshift cluster running. + +--- +== Steps to forward to Cloudwatch using Web Identity + +=== Creating a `CredentialsRequest` +. Create a `CredentialsRequest` resource with the appropriate actions. +.. This example `CredentialsRequest` allows creating and describing logs. ++ +.aws-cred-request.yaml +[source, yaml] +---- +apiVersion: cloudcredential.openshift.io/v1 +kind: CredentialsRequest +metadata: + name: my-credrequest # <1> + namespace: openshift-logging <1> +spec: + providerSpec: + apiVersion: cloudcredential.openshift.io/v1 + kind: AWSProviderSpec + statementEntries: + - action: # <2> + - logs:PutLogEvents + - logs:CreateLogGroup + - logs:PutRetentionPolicy + - logs:CreateLogStream + - logs:DescribeLogGroups + - logs:DescribeLogStreams + effect: Allow + resource: arn:aws:logs:*:*:* + secretRef: + name: sts-secret # <3> + namespace: openshift-logging # <3> + serviceAccountNames: + - my-sa # <4> +---- +<1> The name and namespace for the credentials request. +<2> The allowed actions for this role. +<3> The name and namespace of the secret containing the generated credentials. +<4> The service account(s) that will use the credentials ++ + +. Create the role in `AWS` using the <> utility. ++ +``` +$ ccoctl aws create-iam-roles --name= --region= \ # <1> + --credentials-requests-dir= \ # <2> + --output-dir= \ # <3> + --identity-provider-arn=arn:aws:iam:::oidc-provider/-oidc.s3..amazonaws.com # <4> +``` +<1> The name of the resource along with the region +<2> The credentials request directory where the above `CredentialsRequest` YAML is saved. +<3> The output directory +<4> The identity provider arn ++ + +. Apply the generated credentials secret. ++ +.openshift-logging-sts-secret-credentials.yaml +[source, yaml] +---- +apiVersion: v1 +stringData: + credentials: |- + [default] + sts_regional_endpoints = regional + role_arn = + web_identity_token_file = +kind: Secret +metadata: + name: sts-secret + namespace: openshift-logging +type: Opaque +---- ++ +``` +$ oc apply -f openshift-logging-sts-secret-credentials.yaml +``` + + +=== Configuring a `ClusterLogForwarder` + +This example forwarder shows two ways to configure a `cloudwatch` output, with a projected service account token or using a secret containing the service account token. + +. Create a `ClusterLogForwarder.yaml` ++ +.cluster-log-forwarder.yaml +[source,yaml] +---- +apiVersion: observability.openshift.io/v1 +kind: ClusterLogForwarder +metadata: + name: cw-forwarder # <1> + namespace: openshift-logging # <1> +spec: + serviceAccount: + name: my-sa # <2> + outputs: + - name: cw-sa-projected-token + type: cloudwatch + cloudwatch: + groupName: 'cw-projected{.log_type||"missing"}' # <3> + region: us-west-1 + authentication: + type: iamRole # <4> + iamRole: + roleARN: # <5> + key: credentials # <5> + secretName: sts-secret # <5> + token: # <6> + from: serviceAccount # <6> + - name: cw-sa-token-secret + type: cloudwatch + cloudwatch: + groupName: 'cw-token-secret{.kubernetes.namespace_name||.log_type||"missing"}' + region: us-west-1 + authentication: + type: iamRole + iamRole: + roleARN: + key: role_arn + secretName: foo-sts-secret + token: + from: secret # <7> + key: token # <7> + name: my-sa-token-secret # <7> + pipelines: + - name: app-logs + inputRefs: + - application + outputRefs: + - cw-sa-projected-token + - cw-sa-token-secret +---- +<1> The name and namespace of the forwarder +<2> The service account with the appropriate collection permissions +<3> Group name for the log stream. Can be templated. +<4> The authentication type. For `STS`, use `iamRole`. +<5> The `role_arn` used to authenticate. Specify the name of the secret and the key where the `role_arn` is stored. +<6> The service account token used to authenticate. To use the projected service account token, specify `from: serviceAccount`. +<7> To use a token from a secret, specify `from: secret` and provide the key and secret name ++ + +. Apply the configured forwarder. ++ +``` +$ oc apply -f cluster-log-forwarder.yaml +``` + +== References +=== Openshift + +. [[setup-sts]] https://github.com/openshift/cloud-credential-operator/blob/master/docs/sts.md[Setting up an STS cluster] +. [[cco]] https://github.com/openshift/cloud-credential-operator[Cloud Credential Operator (CCO)] +. https://docs.redhat.com/en/documentation/openshift_container_platform/4.18/html/logging/index[Openshift Logging Documentation] + +=== Amazon +. [[aws-sts]] https://docs.aws.amazon.com/STS/latest/APIReference/welcome.html[AWS Security Token Service (STS)] +. \ No newline at end of file diff --git a/internal/collector/cloudwatch.go b/internal/collector/cloudwatch.go deleted file mode 100644 index 8515f4f4f6..0000000000 --- a/internal/collector/cloudwatch.go +++ /dev/null @@ -1,57 +0,0 @@ -package collector - -import ( - log "github.com/ViaQ/logerr/v2/log/static" - obs "github.com/openshift/cluster-logging-operator/api/observability/v1" - "github.com/openshift/cluster-logging-operator/internal/api/observability" - "github.com/openshift/cluster-logging-operator/internal/collector/common" - "github.com/openshift/cluster-logging-operator/internal/constants" - "github.com/openshift/cluster-logging-operator/internal/generator/vector/output/cloudwatch" - v1 "k8s.io/api/core/v1" -) - -// Add volumes and env vars if output type is cloudwatch and role is found in the secret -func addWebIdentityForCloudwatch(collector *v1.Container, forwarderSpec obs.ClusterLogForwarderSpec, secrets observability.Secrets) { - if secrets == nil { - return - } - for _, o := range forwarderSpec.Outputs { - if o.Type == obs.OutputTypeCloudwatch && o.Cloudwatch.Authentication != nil && o.Cloudwatch.Authentication.Type == obs.CloudwatchAuthTypeIAMRole { - - if roleARN := cloudwatch.ParseRoleArn(o.Cloudwatch.Authentication, secrets); roleARN != "" { - tokenPath := common.ServiceAccountBasePath(constants.TokenKey) - if o.Cloudwatch.Authentication.IAMRole.Token.From == obs.BearerTokenFromSecret { - secret := o.Cloudwatch.Authentication.IAMRole.Token.Secret - tokenPath = common.SecretPath(secret.Name, secret.Key) - } - - AddWebIdentityTokenEnvVars(collector, o.Cloudwatch.Region, roleARN, tokenPath) - } - } - } -} - -// AddWebIdentityTokenEnvVars Appends web identity env vars based on attributes of the secret and forwarder spec -func AddWebIdentityTokenEnvVars(collector *v1.Container, region, roleARN, tokenPath string) { - - // Necessary for vector to use sts - log.V(3).Info("Adding env vars for vector sts Cloudwatch") - collector.Env = append(collector.Env, - v1.EnvVar{ - Name: constants.AWSRegionEnvVarKey, - Value: region, - }, - v1.EnvVar{ - Name: constants.AWSRoleArnEnvVarKey, - Value: roleARN, - }, - v1.EnvVar{ - Name: constants.AWSRoleSessionEnvVarKey, - Value: constants.AWSRoleSessionName, - }, - v1.EnvVar{ - Name: constants.AWSWebIdentityTokenEnvVarKey, - Value: tokenPath, - }, - ) -} diff --git a/internal/collector/cloudwatch/cloudwatch.credentials.tmpl b/internal/collector/cloudwatch/cloudwatch.credentials.tmpl new file mode 100644 index 0000000000..00ea0d7d57 --- /dev/null +++ b/internal/collector/cloudwatch/cloudwatch.credentials.tmpl @@ -0,0 +1,7 @@ +{{- range . -}} +[output_{{ .Name }}] +web_identity_token_file={{ .WebIdentityTokenFile }} +role_arn={{ .RoleARN }} +role_session_name=output-{{ .Name }} + +{{end -}} \ No newline at end of file diff --git a/internal/collector/cloudwatch/cloudwatch.go b/internal/collector/cloudwatch/cloudwatch.go new file mode 100644 index 0000000000..82ee2b1094 --- /dev/null +++ b/internal/collector/cloudwatch/cloudwatch.go @@ -0,0 +1,92 @@ +package cloudwatch + +import ( + _ "embed" + "html/template" + "strings" + + log "github.com/ViaQ/logerr/v2/log/static" + obs "github.com/openshift/cluster-logging-operator/api/observability/v1" + "github.com/openshift/cluster-logging-operator/internal/api/observability" + "github.com/openshift/cluster-logging-operator/internal/collector/common" + "github.com/openshift/cluster-logging-operator/internal/constants" + "github.com/openshift/cluster-logging-operator/internal/generator/vector/output/cloudwatch" + "github.com/openshift/cluster-logging-operator/internal/reconcile" + "github.com/openshift/cluster-logging-operator/internal/runtime" + "github.com/openshift/cluster-logging-operator/internal/utils" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +var ( + CloudwatchCredentialsTemplate = template.Must(template.New("cw credentials").Parse(cloudwatchCredentialsTemplateStr)) + //go:embed cloudwatch.credentials.tmpl + cloudwatchCredentialsTemplateStr string +) + +type CloudwatchWebIdentity struct { + Name string + RoleARN string + WebIdentityTokenFile string +} + +// ReconcileAWSCredentialsConfigMap reconciles a configmap with credential profile(s) for Cloudwatch output(s). +func ReconcileAWSCredentialsConfigMap(k8sClient client.Client, reader client.Reader, namespace, name string, outputs []obs.OutputSpec, secrets observability.Secrets, configMaps map[string]*corev1.ConfigMap, owner metav1.OwnerReference) (*corev1.ConfigMap, error) { + log.V(3).Info("generating AWS ConfigMap") + credString, err := GenerateCloudwatchCredentialProfiles(outputs, secrets) + + if err != nil || credString == "" { + return nil, err + } + + configMap := runtime.NewConfigMap( + namespace, + name, + map[string]string{ + constants.AWSCredentialsKey: credString, + }) + + utils.AddOwnerRefToObject(configMap, owner) + return configMap, reconcile.Configmap(k8sClient, k8sClient, configMap) +} + +// GenerateCloudwatchCredentialProfiles generates AWS CLI profiles for a credentials file from spec'd cloudwatch role ARNs and returns the formatted content as a string. +func GenerateCloudwatchCredentialProfiles(outputs []obs.OutputSpec, secrets observability.Secrets) (string, error) { + // Gather all cloudwatch output's role_arns/tokens + webIds := GatherAWSWebIdentities(outputs, secrets) + + // No CW outputs + if webIds == nil { + return "", nil + } + + // Execute Go template to generate credential profile(s) + w := &strings.Builder{} + err := CloudwatchCredentialsTemplate.Execute(w, webIds) + if err != nil { + return "", err + } + return w.String(), nil +} + +// GatherAWSWebIdentities takes spec'd role arns and generates CloudwatchWebIdentity objects with a name and token path from secret or projected SA token +func GatherAWSWebIdentities(outputs []obs.OutputSpec, secrets observability.Secrets) (webIds []CloudwatchWebIdentity) { + for _, o := range outputs { + if o.Type == obs.OutputTypeCloudwatch && o.Cloudwatch.Authentication != nil && o.Cloudwatch.Authentication.Type == obs.CloudwatchAuthTypeIAMRole { + if roleARN := cloudwatch.ParseRoleArn(o.Cloudwatch.Authentication, secrets); roleARN != "" { + tokenPath := common.ServiceAccountBasePath(constants.TokenKey) + if o.Cloudwatch.Authentication.IAMRole.Token.From == obs.BearerTokenFromSecret { + secret := o.Cloudwatch.Authentication.IAMRole.Token.Secret + tokenPath = common.SecretPath(secret.Name, secret.Key) + } + webIds = append(webIds, CloudwatchWebIdentity{ + Name: o.Name, + RoleARN: roleARN, + WebIdentityTokenFile: tokenPath, + }) + } + } + } + return webIds +} diff --git a/internal/collector/cloudwatch/cloudwatch_test.go b/internal/collector/cloudwatch/cloudwatch_test.go new file mode 100644 index 0000000000..481319c6bd --- /dev/null +++ b/internal/collector/cloudwatch/cloudwatch_test.go @@ -0,0 +1,198 @@ +package cloudwatch_test + +import ( + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + obs "github.com/openshift/cluster-logging-operator/api/observability/v1" + "github.com/openshift/cluster-logging-operator/internal/collector/cloudwatch" + "github.com/openshift/cluster-logging-operator/internal/constants" + v1 "k8s.io/api/core/v1" +) + +var _ = Describe("cloudwatch auth configmap", func() { + const ( + roleArn = "arn:aws:iam::123456789012:role/foo" + roleArn2 = "arn:aws:iam::123456789012:role/bar" + saTokenPath = "/var/run/ocp-collector/serviceaccount/token" + region = "us-west-1" + ) + Context("generating CloudwatchWebIdentity objects", func() { + var ( + cwSecret map[string]*v1.Secret + ) + + BeforeEach(func() { + cwSecret = map[string]*v1.Secret{ + "cw-secret": { + Data: map[string][]byte{ + "role_arn1": []byte(roleArn), + "role_arn2": []byte(roleArn2), + "token": []byte("my-token"), + }, + }, + } + }) + + It("should be nil if no cloudwatch outputs", func() { + outputs := []obs.OutputSpec{ + { + Name: "es-out", + Type: obs.OutputTypeElasticsearch, + }, + } + Expect(cloudwatch.GatherAWSWebIdentities(outputs, cwSecret)).To(BeNil()) + }) + + It("should be nil if secrets are nil and no cloudwatch outputs", func() { + outputs := []obs.OutputSpec{ + { + Name: "es-out", + Type: obs.OutputTypeElasticsearch, + }, + } + + Expect(cloudwatch.GatherAWSWebIdentities(outputs, nil)).To(BeNil()) + }) + + It("should be nil if secrets are nil and outputs are nil", func() { + Expect(cloudwatch.GatherAWSWebIdentities(nil, nil)).To(BeNil()) + }) + + DescribeTable("token path", func(token obs.BearerToken, exp cloudwatch.CloudwatchWebIdentity) { + cwOutputs := []obs.OutputSpec{ + { + Name: "cw-out", + Type: obs.OutputTypeCloudwatch, + Cloudwatch: &obs.Cloudwatch{ + Authentication: &obs.CloudwatchAuthentication{ + Type: obs.CloudwatchAuthTypeIAMRole, + IAMRole: &obs.CloudwatchIAMRole{ + RoleARN: obs.SecretReference{ + Key: "role_arn1", + SecretName: "cw-secret", + }, + Token: token, + }, + }, + Region: region, + }, + }, + } + actIds := cloudwatch.GatherAWSWebIdentities(cwOutputs, cwSecret) + Expect(actIds[0]).To(Equal(exp)) + }, + Entry("should get token from secret", obs.BearerToken{ + From: obs.BearerTokenFromSecret, + Secret: &obs.BearerTokenSecretKey{ + Key: constants.TokenKey, + Name: "cw-secret", + }, + }, cloudwatch.CloudwatchWebIdentity{ + Name: "cw-out", + RoleARN: roleArn, + WebIdentityTokenFile: "/var/run/ocp-collector/secrets/cw-secret/token", + }), + Entry("should get token from serviceAccount", obs.BearerToken{ + From: obs.BearerTokenFromServiceAccount, + }, cloudwatch.CloudwatchWebIdentity{ + Name: "cw-out", + RoleARN: roleArn, + WebIdentityTokenFile: "/var/run/ocp-collector/serviceaccount/token", + })) + + It("should gather all role_arns/tokens from cw outputs", func() { + cwOutputs := []obs.OutputSpec{ + { + Name: "cw-out1", + Type: obs.OutputTypeCloudwatch, + Cloudwatch: &obs.Cloudwatch{ + Authentication: &obs.CloudwatchAuthentication{ + Type: obs.CloudwatchAuthTypeIAMRole, + IAMRole: &obs.CloudwatchIAMRole{ + RoleARN: obs.SecretReference{ + Key: "role_arn1", + SecretName: "cw-secret", + }, + Token: obs.BearerToken{ + From: obs.BearerTokenFromServiceAccount, + }, + }, + }, + Region: region, + }, + }, + { + Name: "cw-out2", + Type: obs.OutputTypeCloudwatch, + Cloudwatch: &obs.Cloudwatch{ + Authentication: &obs.CloudwatchAuthentication{ + Type: obs.CloudwatchAuthTypeIAMRole, + IAMRole: &obs.CloudwatchIAMRole{ + RoleARN: obs.SecretReference{ + Key: "role_arn2", + SecretName: "cw-secret", + }, + Token: obs.BearerToken{ + From: obs.BearerTokenFromServiceAccount, + }, + }, + }, + Region: region, + }, + }, + } + + expCreds := []cloudwatch.CloudwatchWebIdentity{ + { + Name: cwOutputs[0].Name, + RoleARN: roleArn, + WebIdentityTokenFile: saTokenPath, + }, + { + Name: cwOutputs[1].Name, + RoleARN: roleArn2, + WebIdentityTokenFile: saTokenPath, + }, + } + + actIds := cloudwatch.GatherAWSWebIdentities(cwOutputs, cwSecret) + Expect(actIds).To(Equal(expCreds)) + }) + }) + + DescribeTable("cloudwatch credential go template", func(creds []cloudwatch.CloudwatchWebIdentity, expFile string) { + exp, err := credFiles.ReadFile(expFile) + Expect(err).To(BeNil()) + + w := &strings.Builder{} + err = cloudwatch.CloudwatchCredentialsTemplate.Execute(w, creds) + + Expect(err).To(BeNil()) + Expect(w.String()).To(Equal(string(exp))) + }, + Entry("should generate one profile", []cloudwatch.CloudwatchWebIdentity{ + { + Name: "default", + RoleARN: "arn:aws:iam::123456789012:role/test-default", + WebIdentityTokenFile: saTokenPath, + }}, "cw_single_credential"), + Entry("should generate multiple profiles when multiple credentials are present", []cloudwatch.CloudwatchWebIdentity{ + { + Name: "default", + RoleARN: "arn:aws:iam::123456789012:role/test-default", + WebIdentityTokenFile: saTokenPath, + }, + { + Name: "foo", + RoleARN: "arn:aws:iam::123456789012:role/test-foo", + WebIdentityTokenFile: saTokenPath, + }, + { + Name: "bar", + RoleARN: "arn:aws:iam::123456789012:role/test-bar", + WebIdentityTokenFile: saTokenPath, + }, + }, "cw_multiple_credentials")) +}) diff --git a/internal/collector/cloudwatch/cw_multiple_credentials b/internal/collector/cloudwatch/cw_multiple_credentials new file mode 100644 index 0000000000..6916ac2436 --- /dev/null +++ b/internal/collector/cloudwatch/cw_multiple_credentials @@ -0,0 +1,15 @@ +[output_default] +web_identity_token_file=/var/run/ocp-collector/serviceaccount/token +role_arn=arn:aws:iam::123456789012:role/test-default +role_session_name=output-default + +[output_foo] +web_identity_token_file=/var/run/ocp-collector/serviceaccount/token +role_arn=arn:aws:iam::123456789012:role/test-foo +role_session_name=output-foo + +[output_bar] +web_identity_token_file=/var/run/ocp-collector/serviceaccount/token +role_arn=arn:aws:iam::123456789012:role/test-bar +role_session_name=output-bar + diff --git a/internal/collector/cloudwatch/cw_single_credential b/internal/collector/cloudwatch/cw_single_credential new file mode 100644 index 0000000000..23e7d3ac54 --- /dev/null +++ b/internal/collector/cloudwatch/cw_single_credential @@ -0,0 +1,5 @@ +[output_default] +web_identity_token_file=/var/run/ocp-collector/serviceaccount/token +role_arn=arn:aws:iam::123456789012:role/test-default +role_session_name=output-default + diff --git a/internal/collector/cloudwatch/suite_test.go b/internal/collector/cloudwatch/suite_test.go new file mode 100644 index 0000000000..a092e4edae --- /dev/null +++ b/internal/collector/cloudwatch/suite_test.go @@ -0,0 +1,19 @@ +package cloudwatch_test + +import ( + "embed" + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var ( + //go:embed cw_multiple_credentials cw_single_credential + credFiles embed.FS +) + +func TestCollectorCloudwatch(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "[internal][collector][cloudwatch] suite") +} diff --git a/internal/collector/collector.go b/internal/collector/collector.go index 1bb8e33974..ef9923b361 100644 --- a/internal/collector/collector.go +++ b/internal/collector/collector.go @@ -166,7 +166,6 @@ func (f *Factory) NewPodSpec(trustedCABundle *v1.ConfigMap, spec obs.ClusterLogF addTrustedCABundle(collector, podSpec, trustedCABundle) f.Visit(collector, podSpec, f.ResourceNames, namespace, f.LogLevel) - addWebIdentityForCloudwatch(collector, spec, f.Secrets) podSpec.Containers = []v1.Container{ *collector, diff --git a/internal/collector/collector_test.go b/internal/collector/collector_test.go index e3e06f7fe4..cf8454050c 100644 --- a/internal/collector/collector_test.go +++ b/internal/collector/collector_test.go @@ -803,32 +803,6 @@ var _ = Describe("Factory#NewPodSpec Add Cloudwatch STS Resources", func() { } }) Context("when collector has a secret containing a credentials key", func() { - - It("should add the AWS web identity env vars in the container", func() { - podSpec := *factory.NewPodSpec(nil, obs.ClusterLogForwarderSpec{ - Outputs: outputs, - Pipelines: pipelines, - }, "1234", tls.GetClusterTLSProfileSpec(nil), constants.OpenshiftNS) - collector := podSpec.Containers[0] - - Expect(collector.Env).To(IncludeEnvVar(v1.EnvVar{ - Name: constants.AWSRegionEnvVarKey, - Value: outputs[0].Cloudwatch.Region, - })) - Expect(collector.Env).To(IncludeEnvVar(v1.EnvVar{ - Name: constants.AWSRoleArnEnvVarKey, - Value: roleArn, - })) - Expect(collector.Env).To(IncludeEnvVar(v1.EnvVar{ - Name: constants.AWSRoleSessionEnvVarKey, - Value: constants.AWSRoleSessionName, - })) - Expect(collector.Env).To(IncludeEnvVar(v1.EnvVar{ - Name: constants.AWSWebIdentityTokenEnvVarKey, - Value: path.Join(constants.ServiceAccountSecretPath, constants.TokenKey), - })) - }) - It("should mount the secret for the bearer token when spec'd", func() { outputs[0].Cloudwatch.Authentication.IAMRole.Token = bearerToken podSpec := *factory.NewPodSpec(nil, obs.ClusterLogForwarderSpec{ diff --git a/internal/constants/constants.go b/internal/constants/constants.go index 0e8e885606..e16778bf56 100644 --- a/internal/constants/constants.go +++ b/internal/constants/constants.go @@ -24,8 +24,9 @@ const ( AWSSecretAccessKey = "aws_secret_access_key" //nolint:gosec AWSAccessKeyID = "aws_access_key_id" AWSRoleSessionName = "cluster-logging" // identifier for role logging session - AWSCredentialsKey = "credentials" // credrequest key to check for sts-formatted secret - AWSWebIdentityRoleKey = "role_arn" // manual key to check for sts-formatted secret + AWSCredentialsConfigMapName = "aws-creds" + AWSCredentialsKey = "credentials" // credrequest key to check for sts-formatted secret + AWSWebIdentityRoleKey = "role_arn" // manual key to check for sts-formatted secret AWSRegionEnvVarKey = "AWS_REGION" AWSRoleArnEnvVarKey = "AWS_ROLE_ARN" AWSRoleSessionEnvVarKey = "AWS_ROLE_SESSION_NAME" diff --git a/internal/controller/observability/collector.go b/internal/controller/observability/collector.go index 858df133c9..97d1cf1b83 100644 --- a/internal/controller/observability/collector.go +++ b/internal/controller/observability/collector.go @@ -10,6 +10,7 @@ import ( internalobs "github.com/openshift/cluster-logging-operator/internal/api/observability" "github.com/openshift/cluster-logging-operator/internal/auth" "github.com/openshift/cluster-logging-operator/internal/collector" + "github.com/openshift/cluster-logging-operator/internal/collector/cloudwatch" "github.com/openshift/cluster-logging-operator/internal/constants" "github.com/openshift/cluster-logging-operator/internal/factory" forwardergenerator "github.com/openshift/cluster-logging-operator/internal/generator/forwarder" @@ -72,6 +73,17 @@ func ReconcileCollector(context internalcontext.ForwarderContext, pollInterval, } trustedCABundle := collector.WaitForTrustedCAToBePopulated(context.Client, context.Forwarder.Namespace, resourceNames.CaTrustBundle, pollInterval, timeout) + credCm, err := cloudwatch.ReconcileAWSCredentialsConfigMap(context.Client, context.Reader, context.Forwarder.Namespace, resourceNames.AwsCredentialsFile, context.Forwarder.Spec.Outputs, context.Secrets, context.ConfigMaps, ownerRef) + if err != nil { + log.V(9).Error(err, "collector.ReconcileAWSProfileConfig") + return err + } + + // Add generated credentials configmap to contexts to be mounted in pod + if credCm != nil { + context.ConfigMaps[credCm.Name] = credCm + } + var collectorConfig string if collectorConfig, err = GenerateConfig(context.Client, *context.Forwarder, *resourceNames, context.Secrets, options); err != nil { log.V(9).Error(err, "collector.GenerateConfig") diff --git a/internal/factory/resource_names.go b/internal/factory/resource_names.go index 8648f321f0..9b8702d5fe 100644 --- a/internal/factory/resource_names.go +++ b/internal/factory/resource_names.go @@ -2,7 +2,9 @@ package factory import ( "fmt" + obsv1 "github.com/openshift/cluster-logging-operator/api/observability/v1" + "github.com/openshift/cluster-logging-operator/internal/constants" ) type ForwarderResourceNames struct { @@ -16,6 +18,7 @@ type ForwarderResourceNames struct { ServiceAccountTokenSecret string ForwarderName string Secrets string + AwsCredentialsFile string } func (f *ForwarderResourceNames) DaemonSetName() string { @@ -41,5 +44,6 @@ func ResourceNames(clf obsv1.ClusterLogForwarder) *ForwarderResourceNames { InternalLogStoreSecret: clf.Spec.ServiceAccount.Name + "-default", ServiceAccountTokenSecret: clf.Spec.ServiceAccount.Name + "-token", Secrets: resBaseName + "-secrets", + AwsCredentialsFile: resBaseName + "-" + constants.AWSCredentialsConfigMapName, } } diff --git a/internal/generator/framework/options.go b/internal/generator/framework/options.go index 0beef3b3b9..4033bdb669 100644 --- a/internal/generator/framework/options.go +++ b/internal/generator/framework/options.go @@ -12,6 +12,7 @@ const ( URL = "url" OptionServiceAccountTokenSecretName = "serviceAccountTokenSecretName" + OptionForwarderName = "forwarderName" ) // Options is a map of Options used to customize the config generation. E.g. Debugging, legacy config generation diff --git a/internal/generator/vector/conf/conf.go b/internal/generator/vector/conf/conf.go index 47182c913a..89da484a7e 100644 --- a/internal/generator/vector/conf/conf.go +++ b/internal/generator/vector/conf/conf.go @@ -1,9 +1,10 @@ package conf import ( + "sort" + obs "github.com/openshift/cluster-logging-operator/api/observability/v1" internalobs "github.com/openshift/cluster-logging-operator/internal/api/observability" - "sort" "github.com/openshift/cluster-logging-operator/internal/factory" "github.com/openshift/cluster-logging-operator/internal/generator/framework" @@ -69,6 +70,7 @@ func Conf(secrets map[string]*corev1.Secret, clfspec obs.ClusterLogForwarderSpec } outputMap := map[string]*output.Output{} + op[framework.OptionForwarderName] = forwarderName for _, spec := range clfspec.Outputs { o := output.NewOutput(spec, secrets, op) outputMap[spec.Name] = o diff --git a/internal/generator/vector/output/cloudwatch/auth.go b/internal/generator/vector/output/cloudwatch/auth.go index 31949de3ff..f33e4d2e40 100644 --- a/internal/generator/vector/output/cloudwatch/auth.go +++ b/internal/generator/vector/output/cloudwatch/auth.go @@ -8,6 +8,7 @@ type Auth struct { KeyID OptionalPair KeySecret OptionalPair CredentialsPath OptionalPair + Profile OptionalPair } func NewAuth() Auth { @@ -15,6 +16,7 @@ func NewAuth() Auth { KeyID: NewOptionalPair("auth.access_key_id", nil), KeySecret: NewOptionalPair("auth.secret_access_key", nil), CredentialsPath: NewOptionalPair("auth.credentials_file", nil), + Profile: NewOptionalPair("auth.profile", nil), } } @@ -27,5 +29,6 @@ func (a Auth) Template() string { {{.KeyID}} {{.KeySecret}} {{.CredentialsPath}} +{{.Profile}} {{- end}}` } diff --git a/internal/generator/vector/output/cloudwatch/cloudwatch.go b/internal/generator/vector/output/cloudwatch/cloudwatch.go index 113117a5be..4d3b9969cb 100644 --- a/internal/generator/vector/output/cloudwatch/cloudwatch.go +++ b/internal/generator/vector/output/cloudwatch/cloudwatch.go @@ -2,10 +2,13 @@ package cloudwatch import ( _ "embed" - "github.com/openshift/cluster-logging-operator/internal/api/observability" "regexp" "strings" + "github.com/openshift/cluster-logging-operator/internal/api/observability" + "github.com/openshift/cluster-logging-operator/internal/constants" + "github.com/openshift/cluster-logging-operator/internal/utils" + obs "github.com/openshift/cluster-logging-operator/api/observability/v1" . "github.com/openshift/cluster-logging-operator/internal/generator/framework" "github.com/openshift/cluster-logging-operator/internal/generator/vector/output/common" @@ -107,17 +110,22 @@ func sink(id string, o obs.OutputSpec, inputs []string, secrets observability.Se Inputs: vectorhelpers.MakeInputs(inputs...), Region: region, GroupName: groupName, - SecurityConfig: authConfig(o.Cloudwatch.Authentication, secrets), + SecurityConfig: authConfig(o.Name, o.Cloudwatch.Authentication, op), EndpointConfig: endpointConfig(o.Cloudwatch), RootMixin: common.NewRootMixin("none"), } } -func authConfig(auth *obs.CloudwatchAuthentication, secrets observability.Secrets) Element { +func authConfig(outputName string, auth *obs.CloudwatchAuthentication, options Options) Element { authConfig := NewAuth() if auth != nil && auth.Type == obs.CloudwatchAuthTypeAccessKey { authConfig.KeyID.Value = vectorhelpers.SecretFrom(&auth.AWSAccessKey.KeyId) authConfig.KeySecret.Value = vectorhelpers.SecretFrom(&auth.AWSAccessKey.KeySecret) + } else if auth != nil && auth.Type == obs.CloudwatchAuthTypeIAMRole { + if forwarderName, found := utils.GetOption(options, OptionForwarderName, ""); found { + authConfig.CredentialsPath.Value = strings.Trim(vectorhelpers.ConfigPath(forwarderName+"-"+constants.AWSCredentialsConfigMapName, constants.AWSCredentialsKey), `"`) + authConfig.Profile.Value = "output_" + outputName + } } return authConfig } diff --git a/internal/generator/vector/output/cloudwatch/cloudwatch_test.go b/internal/generator/vector/output/cloudwatch/cloudwatch_test.go index 4123937e80..5693258ae1 100644 --- a/internal/generator/vector/output/cloudwatch/cloudwatch_test.go +++ b/internal/generator/vector/output/cloudwatch/cloudwatch_test.go @@ -95,6 +95,7 @@ var _ = Describe("Generating vector config for cloudwatch output", func() { secretWithCredentials: { Data: map[string][]byte{ constants.AWSCredentialsKey: []byte("[default]\nrole_arn = " + roleArn + "\nweb_identity_token_file = /var/run/secrets/token"), + "my_role_arn": []byte(roleArn), constants.ClientPrivateKey: []byte("-- key-- "), constants.TrustedCABundleKey: []byte("-- ca-bundle -- "), }, @@ -123,6 +124,7 @@ var _ = Describe("Generating vector config for cloudwatch output", func() { if tune { adapter = *fake.NewOutput(outputSpec, secrets, framework.NoOptions) } + op[framework.OptionForwarderName] = "my-forwarder" conf := New(outputSpec.Name, outputSpec, []string{"cw-forward"}, secrets, adapter, op) Expect(string(exp)).To(EqualConfigFrom(conf)) }, @@ -153,6 +155,20 @@ var _ = Describe("Generating vector config for cloudwatch output", func() { }, } }, false, framework.NoOptions, "cw_groupname_with_aws_credentials.toml"), + Entry("when a role_arn is provided", `app-{.log_type||"missing"}`, func(spec *obs.OutputSpec) { + spec.Cloudwatch.Authentication = &obs.CloudwatchAuthentication{ + Type: obs.CloudwatchAuthTypeIAMRole, + IAMRole: &obs.CloudwatchIAMRole{ + RoleARN: obs.SecretReference{ + Key: "my_role_arn", + SecretName: secretWithCredentials, + }, + Token: obs.BearerToken{ + From: obs.BearerTokenFromServiceAccount, + }, + }, + } + }, false, framework.NoOptions, "cw_groupname_with_aws_credentials.toml"), Entry("when tuning is spec'd", `{.log_type||"missing"}`, func(spec *obs.OutputSpec) { spec.Cloudwatch.Tuning = baseTune }, true, framework.NoOptions, "cw_with_tuning.toml"), diff --git a/internal/generator/vector/output/cloudwatch/cw_groupname_with_aws_credentials.toml b/internal/generator/vector/output/cloudwatch/cw_groupname_with_aws_credentials.toml index 7961e2c3a4..b0e1544ee1 100644 --- a/internal/generator/vector/output/cloudwatch/cw_groupname_with_aws_credentials.toml +++ b/internal/generator/vector/output/cloudwatch/cw_groupname_with_aws_credentials.toml @@ -37,6 +37,8 @@ region = "us-east-test" compression = "none" group_name = "{{ _internal.cw_group_name }}" stream_name = "{{ stream_name }}" +auth.credentials_file = "/var/run/ocp-collector/config/my-forwarder-aws-creds/credentials" +auth.profile = "output_cw" healthcheck.enabled = false [sinks.cw.encoding] diff --git a/internal/validations/observability/outputs/cloudwatch.go b/internal/validations/observability/outputs/cloudwatch.go index 6b47097362..e5685b60d1 100644 --- a/internal/validations/observability/outputs/cloudwatch.go +++ b/internal/validations/observability/outputs/cloudwatch.go @@ -1,34 +1,27 @@ package outputs import ( - "github.com/golang-collections/collections/set" obs "github.com/openshift/cluster-logging-operator/api/observability/v1" internalcontext "github.com/openshift/cluster-logging-operator/internal/api/context" "github.com/openshift/cluster-logging-operator/internal/api/observability" "github.com/openshift/cluster-logging-operator/internal/generator/vector/output/cloudwatch" - "github.com/openshift/cluster-logging-operator/internal/utils" ) const ( - RoleARNsOpt = "roleARNs" - ErrVariousRoleARNAuth = "Found multiple different CloudWatch RoleARN authorizations in the outputs spec" + RoleARNsOpt = "roleARNs" + ErrInvalidRoleARN = "CloudWatch RoleARN is invalid" ) func ValidateCloudWatchAuth(spec obs.OutputSpec, context internalcontext.ForwarderContext) (results []string) { secrets := observability.Secrets(context.Secrets) - additionalContext := context.AdditionalContext authSpec := spec.Cloudwatch.Authentication + // Validate role ARN if authSpec.Type == obs.CloudwatchAuthTypeIAMRole { roleArn := cloudwatch.ParseRoleArn(authSpec, secrets) - roleARNs := set.New(roleArn) - utils.Update(additionalContext, RoleARNsOpt, roleARNs, func(existing *set.Set) *set.Set { - existing = existing.Union(roleARNs) - if existing.Len() > 1 { - results = append(results, ErrVariousRoleARNAuth) - } - return existing - }) + if roleArn == "" { + results = append(results, ErrInvalidRoleARN) + } } return results } diff --git a/internal/validations/observability/outputs/cloudwatch_test.go b/internal/validations/observability/outputs/cloudwatch_test.go index 0630168d89..0e3568d935 100644 --- a/internal/validations/observability/outputs/cloudwatch_test.go +++ b/internal/validations/observability/outputs/cloudwatch_test.go @@ -1,13 +1,11 @@ package outputs import ( - "github.com/golang-collections/collections/set" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" obs "github.com/openshift/cluster-logging-operator/api/observability/v1" internalcontext "github.com/openshift/cluster-logging-operator/internal/api/context" "github.com/openshift/cluster-logging-operator/internal/constants" - "github.com/openshift/cluster-logging-operator/internal/utils" corev1 "k8s.io/api/core/v1" v1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -16,9 +14,9 @@ var _ = Describe("validating CloudWatch auth", func() { Context("#ValidateCloudWatchAuth", func() { var ( - myRoleArn = "arn:aws:iam::123456789012:role/my-role-to-assume" - otherRoleArn = "arn:aws:iam::123456789012:role/other-role-to-assume" - spec = obs.OutputSpec{ + myRoleArn = "arn:aws:iam::123456789012:role/my-role-to-assume" + invalidRoleARN = "arn:aws:iam::123456789:role/other-role-to-assume" + spec = obs.OutputSpec{ Name: "output", Type: obs.OutputTypeCloudwatch, Cloudwatch: &obs.Cloudwatch{ @@ -43,6 +41,7 @@ var _ = Describe("validating CloudWatch auth", func() { }, Data: map[string][]byte{ constants.AWSCredentialsKey: []byte(myRoleArn), + "invalidRoleARN": []byte(invalidRoleARN), }, } context = internalcontext.ForwarderContext{ @@ -57,25 +56,17 @@ var _ = Describe("validating CloudWatch auth", func() { } ) - It("should fail validation if meet different Role ARN", func() { - roleARNs := set.New(otherRoleArn) - context.AdditionalContext = utils.Options{ - RoleARNsOpt: roleARNs, - } + It("should pass with valid role arn", func() { res := ValidateCloudWatchAuth(spec, context) - Expect(res).ToNot(BeEmpty()) - Expect(len(res)).To(BeEquivalentTo(1)) - Expect(res[0]).To(BeEquivalentTo(ErrVariousRoleARNAuth)) + Expect(res).To(BeEmpty()) }) - It("should pass validation if Role ARNs are equals", func() { - roleARNs := set.New(myRoleArn) - context.AdditionalContext = utils.Options{ - RoleARNsOpt: roleARNs, - } + It("should fail if Role ARN is invalid", func() { + spec.Cloudwatch.Authentication.IAMRole.RoleARN.Key = "invalidRoleARN" res := ValidateCloudWatchAuth(spec, context) - Expect(res).To(BeEmpty()) + Expect(res).ToNot(BeEmpty()) + Expect(len(res)).To(BeEquivalentTo(1)) + Expect(res[0]).To(BeEquivalentTo(ErrInvalidRoleARN)) }) - }) }) diff --git a/internal/validations/observability/outputs/validate.go b/internal/validations/observability/outputs/validate.go index 5773f27ed6..0200227836 100644 --- a/internal/validations/observability/outputs/validate.go +++ b/internal/validations/observability/outputs/validate.go @@ -2,11 +2,12 @@ package outputs import ( "fmt" + "strings" + obs "github.com/openshift/cluster-logging-operator/api/observability/v1" internalcontext "github.com/openshift/cluster-logging-operator/internal/api/context" internalobs "github.com/openshift/cluster-logging-operator/internal/api/observability" "github.com/openshift/cluster-logging-operator/internal/validations/observability/common" - "strings" ) func Validate(context internalcontext.ForwarderContext) { diff --git a/internal/validations/observability/outputs/validate_test.go b/internal/validations/observability/outputs/validate_test.go index 0eb2f158a9..5425fbd851 100644 --- a/internal/validations/observability/outputs/validate_test.go +++ b/internal/validations/observability/outputs/validate_test.go @@ -136,13 +136,13 @@ var _ = Describe("Validating outputs", func() { []obs.OutputSpec{createIAMRoleSpec("output1", foo), createIAMRoleSpec("output2", foo)}, []string{"is valid", "is valid"}, ), - Entry("should reject multiple CloudWatch outputs with different IAM roles", + Entry("should accept multiple CloudWatch outputs with different IAM roles", []obs.OutputSpec{createIAMRoleSpec("output1", foo), createIAMRoleSpec("output2", bar)}, - []string{"is valid", ErrVariousRoleARNAuth}, + []string{"is valid", "is valid"}, ), - Entry("should reject multiple CloudWatch outputs with different IAM roles and static key", + Entry("should accept multiple CloudWatch outputs with different IAM roles and static key", []obs.OutputSpec{createIAMRoleSpec("output1", foo), createAccessKeySpec("output2", bar), createIAMRoleSpec("output3", bar)}, - []string{"is valid", "is valid", ErrVariousRoleARNAuth}, + []string{"is valid", "is valid", "is valid"}, ), ) }) diff --git a/olm_deploy/scripts/env.sh b/olm_deploy/scripts/env.sh index 4baf524caf..8475ded712 100755 --- a/olm_deploy/scripts/env.sh +++ b/olm_deploy/scripts/env.sh @@ -3,7 +3,7 @@ set -eou pipefail LOGGING_VERSION=${LOGGING_VERSION:-6.1} -LOGGING_VECTOR_VERSION=${LOGGING_VECTOR_VERSION:-6.1} +LOGGING_VECTOR_VERSION=${LOGGING_VECTOR_VERSION:-v0.37.1} LOGGING_LOG_FILE_METRIC_EXPORTER_VERSION=${LOGGING_LOG_FILE_METRIC_EXPORTER_VERSION:-6.1} LOGGING_IS=${LOGGING_IS:-openshift-logging}