diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c547dd..2ec446b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ All notable changes to this project will be documented in this file. - Configuration parameter `spec.nodes.roleGroups..config.discoveryServiceExposed` added to expose a role-group via the discovery service. - Add support for OpenSearch 3.4.0 ([#108]). +- Allow the configuration of the OpenSearch security plugin ([#117]). ### Changed @@ -49,6 +50,7 @@ All notable changes to this project will be documented in this file. [#108]: https://github.com/stackabletech/opensearch-operator/pull/108 [#110]: https://github.com/stackabletech/opensearch-operator/pull/110 [#114]: https://github.com/stackabletech/opensearch-operator/pull/114 +[#117]: https://github.com/stackabletech/opensearch-operator/pull/117 ## [25.11.0] - 2025-11-07 diff --git a/docs/modules/opensearch/examples/getting_started/getting_started.sh b/docs/modules/opensearch/examples/getting_started/getting_started.sh index de50009..986b58e 100755 --- a/docs/modules/opensearch/examples/getting_started/getting_started.sh +++ b/docs/modules/opensearch/examples/getting_started/getting_started.sh @@ -47,7 +47,7 @@ esac echo "Creating OpenSearch security plugin configuration" # tag::apply-security-config[] -kubectl apply -f opensearch-security-config.yaml +kubectl apply -f initial-opensearch-security-config.yaml # end::apply-security-config[] echo "Creating OpenSearch cluster" @@ -91,8 +91,21 @@ curl \ --json '{"name": "Stackable"}' \ "$OPENSEARCH_HOST/sample_index/_doc/1" -# Output: -# {"_index":"sample_index","_id":"1","_version":1,"result":"created","_shards":{"total":2,"successful":1,"failed":0},"_seq_no":0,"_primary_term":1} +# Formatted output: +# { +# "_index": "sample_index", +# "_id": "1", +# "_version": 1, +# "result": "created", +# "_shards": { +# "total": 2, +# "successful": 1, +# "failed": 0 +# }, +# "_seq_no": 0, +# "_primary_term": 1 +# } + curl \ --insecure \ @@ -100,8 +113,18 @@ curl \ --request GET \ "$OPENSEARCH_HOST/sample_index/_doc/1" -# Output: -# {"_index":"sample_index","_id":"1","_version":1,"_seq_no":0,"_primary_term":1,"found":true,"_source":{"name": "Stackable"}} +# Formatted output: +# { +# "_index": "sample_index", +# "_id": "1", +# "_version": 1, +# "_seq_no": 0, +# "_primary_term": 1, +# "found": true, +# "_source": { +# "name": "Stackable" +# } +# } # end::rest-api[] echo diff --git a/docs/modules/opensearch/examples/getting_started/getting_started.sh.j2 b/docs/modules/opensearch/examples/getting_started/getting_started.sh.j2 index 51246e8..c23b438 100755 --- a/docs/modules/opensearch/examples/getting_started/getting_started.sh.j2 +++ b/docs/modules/opensearch/examples/getting_started/getting_started.sh.j2 @@ -47,7 +47,7 @@ esac echo "Creating OpenSearch security plugin configuration" # tag::apply-security-config[] -kubectl apply -f opensearch-security-config.yaml +kubectl apply -f initial-opensearch-security-config.yaml # end::apply-security-config[] echo "Creating OpenSearch cluster" @@ -91,8 +91,21 @@ curl \ --json '{"name": "Stackable"}' \ "$OPENSEARCH_HOST/sample_index/_doc/1" -# Output: -# {"_index":"sample_index","_id":"1","_version":1,"result":"created","_shards":{"total":2,"successful":1,"failed":0},"_seq_no":0,"_primary_term":1} +# Formatted output: +# { +# "_index": "sample_index", +# "_id": "1", +# "_version": 1, +# "result": "created", +# "_shards": { +# "total": 2, +# "successful": 1, +# "failed": 0 +# }, +# "_seq_no": 0, +# "_primary_term": 1 +# } + curl \ --insecure \ @@ -100,8 +113,18 @@ curl \ --request GET \ "$OPENSEARCH_HOST/sample_index/_doc/1" -# Output: -# {"_index":"sample_index","_id":"1","_version":1,"_seq_no":0,"_primary_term":1,"found":true,"_source":{"name": "Stackable"}} +# Formatted output: +# { +# "_index": "sample_index", +# "_id": "1", +# "_version": 1, +# "_seq_no": 0, +# "_primary_term": 1, +# "found": true, +# "_source": { +# "name": "Stackable" +# } +# } # end::rest-api[] echo diff --git a/docs/modules/opensearch/examples/getting_started/initial-opensearch-security-config.yaml b/docs/modules/opensearch/examples/getting_started/initial-opensearch-security-config.yaml new file mode 100644 index 0000000..486f58f --- /dev/null +++ b/docs/modules/opensearch/examples/getting_started/initial-opensearch-security-config.yaml @@ -0,0 +1,34 @@ +--- +apiVersion: v1 +kind: Secret +metadata: + name: initial-opensearch-security-config +stringData: + internal_users.yml: | + --- + _meta: + type: internalusers + config_version: 2 + admin: + hash: $2y$10$xRtHZFJ9QhG9GcYhRpAGpufCZYsk//nxsuel5URh0GWEBgmiI4Q/e + reserved: true + backend_roles: + - admin + description: OpenSearch admin user + kibanaserver: + hash: $2y$10$vPgQ/6ilKDM5utawBqxoR.7euhVQ0qeGl8mPTeKhmFT475WUDrfQS + reserved: true + description: OpenSearch Dashboards user + roles_mapping.yml: | + --- + _meta: + type: rolesmapping + config_version: 2 + all_access: + reserved: false + backend_roles: + - admin + kibana_server: + reserved: true + users: + - kibanaserver diff --git a/docs/modules/opensearch/examples/getting_started/opensearch-dashboards-values.yaml b/docs/modules/opensearch/examples/getting_started/opensearch-dashboards-values.yaml index 1073c1a..9dcac61 100644 --- a/docs/modules/opensearch/examples/getting_started/opensearch-dashboards-values.yaml +++ b/docs/modules/opensearch/examples/getting_started/opensearch-dashboards-values.yaml @@ -17,41 +17,41 @@ config: ssl: verificationMode: full certificateAuthorities: - - /stackable/opensearch-dashboards/config/tls/ca.crt + - /stackable/opensearch-dashboards/config/tls/ca.crt opensearch_security: cookie: secure: true extraEnvs: - - name: OPENSEARCH_HOSTS - valueFrom: - configMapKeyRef: - name: simple-opensearch - key: OPENSEARCH_HOSTS - - name: OPENSEARCH_PASSWORD - valueFrom: - secretKeyRef: - name: opensearch-credentials - key: kibanaserver +- name: OPENSEARCH_HOSTS + valueFrom: + configMapKeyRef: + name: simple-opensearch + key: OPENSEARCH_HOSTS +- name: OPENSEARCH_PASSWORD + valueFrom: + secretKeyRef: + name: opensearch-credentials + key: kibanaserver extraVolumes: - - name: tls - ephemeral: - volumeClaimTemplate: - metadata: - annotations: - secrets.stackable.tech/class: tls - secrets.stackable.tech/scope: service=opensearch-dashboards - spec: - storageClassName: secrets.stackable.tech - accessModes: - - ReadWriteOnce - resources: - requests: - storage: "1" +- name: tls + ephemeral: + volumeClaimTemplate: + metadata: + annotations: + secrets.stackable.tech/class: tls + secrets.stackable.tech/scope: service=opensearch-dashboards + spec: + storageClassName: secrets.stackable.tech + accessModes: + - ReadWriteOnce + resources: + requests: + storage: "1" extraVolumeMounts: - - mountPath: /stackable/opensearch-dashboards/config/tls - name: tls - - mountPath: /stackable/opensearch-dashboards/config/opensearch_dashboards.yml - name: config - subPath: opensearch_dashboards.yml +- mountPath: /stackable/opensearch-dashboards/config/tls + name: tls +- mountPath: /stackable/opensearch-dashboards/config/opensearch_dashboards.yml + name: config + subPath: opensearch_dashboards.yml podSecurityContext: fsGroup: 1000 diff --git a/docs/modules/opensearch/examples/getting_started/opensearch-dashboards-values.yaml.j2 b/docs/modules/opensearch/examples/getting_started/opensearch-dashboards-values.yaml.j2 index 0b3977f..d4e8abe 100644 --- a/docs/modules/opensearch/examples/getting_started/opensearch-dashboards-values.yaml.j2 +++ b/docs/modules/opensearch/examples/getting_started/opensearch-dashboards-values.yaml.j2 @@ -17,41 +17,41 @@ config: ssl: verificationMode: full certificateAuthorities: - - /stackable/opensearch-dashboards/config/tls/ca.crt + - /stackable/opensearch-dashboards/config/tls/ca.crt opensearch_security: cookie: secure: true extraEnvs: - - name: OPENSEARCH_HOSTS - valueFrom: - configMapKeyRef: - name: simple-opensearch - key: OPENSEARCH_HOSTS - - name: OPENSEARCH_PASSWORD - valueFrom: - secretKeyRef: - name: opensearch-credentials - key: kibanaserver +- name: OPENSEARCH_HOSTS + valueFrom: + configMapKeyRef: + name: simple-opensearch + key: OPENSEARCH_HOSTS +- name: OPENSEARCH_PASSWORD + valueFrom: + secretKeyRef: + name: opensearch-credentials + key: kibanaserver extraVolumes: - - name: tls - ephemeral: - volumeClaimTemplate: - metadata: - annotations: - secrets.stackable.tech/class: tls - secrets.stackable.tech/scope: service=opensearch-dashboards - spec: - storageClassName: secrets.stackable.tech - accessModes: - - ReadWriteOnce - resources: - requests: - storage: "1" +- name: tls + ephemeral: + volumeClaimTemplate: + metadata: + annotations: + secrets.stackable.tech/class: tls + secrets.stackable.tech/scope: service=opensearch-dashboards + spec: + storageClassName: secrets.stackable.tech + accessModes: + - ReadWriteOnce + resources: + requests: + storage: "1" extraVolumeMounts: - - mountPath: /stackable/opensearch-dashboards/config/tls - name: tls - - mountPath: /stackable/opensearch-dashboards/config/opensearch_dashboards.yml - name: config - subPath: opensearch_dashboards.yml +- mountPath: /stackable/opensearch-dashboards/config/tls + name: tls +- mountPath: /stackable/opensearch-dashboards/config/opensearch_dashboards.yml + name: config + subPath: opensearch_dashboards.yml podSecurityContext: fsGroup: 1000 diff --git a/docs/modules/opensearch/examples/getting_started/opensearch.yaml b/docs/modules/opensearch/examples/getting_started/opensearch.yaml index 4790255..58ea87b 100644 --- a/docs/modules/opensearch/examples/getting_started/opensearch.yaml +++ b/docs/modules/opensearch/examples/getting_started/opensearch.yaml @@ -6,6 +6,44 @@ metadata: spec: image: productVersion: 3.4.0 + clusterConfig: + security: + settings: + config: + managedBy: operator + content: + value: + _meta: + type: config + config_version: 2 + config: + dynamic: + authc: + basic_internal_auth_domain: + description: Authenticate via HTTP Basic against internal users database + http_enabled: true + transport_enabled: true + order: 1 + http_authenticator: + type: basic + challenge: true + authentication_backend: + type: intern + authz: {} + internalUsers: + managedBy: API + content: + valueFrom: + secretKeyRef: + name: initial-opensearch-security-config + key: internal_users.yml + rolesMapping: + managedBy: API + content: + valueFrom: + secretKeyRef: + name: initial-opensearch-security-config + key: roles_mapping.yml nodes: roleConfig: discoveryServiceListenerClass: external-stable @@ -14,18 +52,4 @@ spec: replicas: 3 configOverrides: opensearch.yml: - plugins.security.allow_default_init_securityindex: "true" plugins.security.restapi.roles_enabled: all_access - podOverrides: - spec: - containers: - - name: opensearch - volumeMounts: - - name: security-config - mountPath: /stackable/opensearch/config/opensearch-security - readOnly: true - volumes: - - name: security-config - secret: - secretName: opensearch-security-config - defaultMode: 0o660 diff --git a/docs/modules/opensearch/pages/getting_started/first_steps.adoc b/docs/modules/opensearch/pages/getting_started/first_steps.adoc index 0119d9a..6e72ccf 100644 --- a/docs/modules/opensearch/pages/getting_started/first_steps.adoc +++ b/docs/modules/opensearch/pages/getting_started/first_steps.adoc @@ -2,20 +2,13 @@ Once you have followed the steps in xref:getting_started/installation.adoc[] for the operator and its dependencies, you will now go through the steps to set up and connect to an OpenSearch instance. -== Security plugin configuration +== User configuration -The configuration for the OpenSearch security plugin must be provided in a separate resource, e.g. a Secret: +Let's begin by defining the users with passwords and their corresponding mappings to back-end roles within a Secret: [source,yaml] ---- -include::example$getting_started/opensearch-security-config.yaml[] ----- - -Apply the Secret: - -[source,bash] ----- -include::example$getting_started/getting_started.sh[tag=apply-security-config] +include::example$getting_started/initial-opensearch-security-config.yaml[] ---- The passwords in `internal_users.yml` are hashes using the bcrypt algorithm. @@ -30,9 +23,17 @@ $ htpasswd -nbBC 10 kibanaserver E4kENuEmkqH3jyHC kibanaserver:$2y$10$vPgQ/6ilKDM5utawBqxoR.7euhVQ0qeGl8mPTeKhmFT475WUDrfQS ---- -== Creation of OpenSearch nodes +Now apply the Secret: -OpenSearch nodes must be created as a custom resource; Create a file called `opensearch.yaml`: +[source,bash] +---- +include::example$getting_started/getting_started.sh[tag=apply-security-config] +---- + +== OpenSearch cluster definition + +OpenSearch nodes must be created as a custom resource; +Create a file called `opensearch.yaml`: [source,yaml] ---- @@ -48,8 +49,6 @@ include::example$getting_started/getting_started.sh[tag=apply-cluster] `metadata.name` contains the name of the OpenSearch cluster. -The previously created security plugin configuration must be referenced via `podOverrides`. - You need to wait for the OpenSearch nodes to finish deploying. You can do so with this command: diff --git a/docs/modules/opensearch/pages/reference/discovery.adoc b/docs/modules/opensearch/pages/reference/discovery.adoc index ad57c0f..cde3685 100644 --- a/docs/modules/opensearch/pages/reference/discovery.adoc +++ b/docs/modules/opensearch/pages/reference/discovery.adoc @@ -36,14 +36,14 @@ spec: config: discoveryServiceExposed: true # <5> nodeRoles: - - cluster_manager + - cluster_manager data: config: discoveryServiceExposed: false # <6> nodeRoles: - - ingest - - data - - remote_cluster_client + - ingest + - data + - remote_cluster_client ---- <1> The name of the OpenSearch cluster which is also the name of the created discovery ConfigMap. <2> The namespace of the cluster and the discovery ConfigMap. diff --git a/docs/modules/opensearch/pages/usage-guide/node-roles.adoc b/docs/modules/opensearch/pages/usage-guide/node-roles.adoc index fa215af..c3a0813 100644 --- a/docs/modules/opensearch/pages/usage-guide/node-roles.adoc +++ b/docs/modules/opensearch/pages/usage-guide/node-roles.adoc @@ -11,10 +11,10 @@ The role configuration already defaults to a set of node roles: nodes: config: nodeRoles: - - cluster_manager - - data - - ingest - - remote_cluster_client + - cluster_manager + - data + - ingest + - remote_cluster_client ---- If you deploy a cluster with the following specification, then 3 replicas with the roles `cluster_manager`, `data`, `ingest` and `remote_cluster_client` are deployed: @@ -40,18 +40,18 @@ nodes: cluster-manager: config: nodeRoles: - - cluster_manager + - cluster_manager replicas: 1 coordinating: config: nodeRoles: - - coordinating_only + - coordinating_only replicas: 1 data: config: nodeRoles: - - data - - ingest + - data + - ingest replicas: 2 ---- @@ -65,4 +65,13 @@ The following roles are currently supported by the operator: * `search` * `warm` +The node role `coordinating_only` cannot be combined with other roles. +`nodeRoles: []` also defines a `coordinating_only` node. + +[WARNING] +==== +Do not remove the `data` node role from an existing role group! +Otherwise you have to repurpose the node to another role manually. +==== + We refer to https://docs.opensearch.org/docs/latest/install-and-configure/configuring-opensearch/configuration-system/[the OpenSearch documentation{external-link-icon}^] for an explanation of the roles. diff --git a/docs/modules/opensearch/pages/usage-guide/opensearch-dashboards.adoc b/docs/modules/opensearch/pages/usage-guide/opensearch-dashboards.adoc index 080405b..eb467ea 100644 --- a/docs/modules/opensearch/pages/usage-guide/opensearch-dashboards.adoc +++ b/docs/modules/opensearch/pages/usage-guide/opensearch-dashboards.adoc @@ -29,42 +29,42 @@ config: ssl: verificationMode: full # <8> certificateAuthorities: - - /stackable/opensearch-dashboards/config/tls/ca.crt # <9> + - /stackable/opensearch-dashboards/config/tls/ca.crt # <9> opensearch_security: cookie: secure: true # <10> extraEnvs: - - name: OPENSEARCH_HOSTS - valueFrom: - configMapKeyRef: - name: opensearch # <11> - key: OPENSEARCH_HOSTS - - name: OPENSEARCH_PASSWORD - valueFrom: - secretKeyRef: - name: opensearch-credentials - key: kibanaserver # <12> +- name: OPENSEARCH_HOSTS + valueFrom: + configMapKeyRef: + name: opensearch # <11> + key: OPENSEARCH_HOSTS +- name: OPENSEARCH_PASSWORD + valueFrom: + secretKeyRef: + name: opensearch-credentials + key: kibanaserver # <12> extraVolumes: - - name: tls # <13> - ephemeral: - volumeClaimTemplate: - metadata: - annotations: - secrets.stackable.tech/class: tls - secrets.stackable.tech/scope: service=opensearch-dashboards - spec: - storageClassName: secrets.stackable.tech - accessModes: - - ReadWriteOnce - resources: - requests: - storage: "1" +- name: tls # <13> + ephemeral: + volumeClaimTemplate: + metadata: + annotations: + secrets.stackable.tech/class: tls + secrets.stackable.tech/scope: service=opensearch-dashboards + spec: + storageClassName: secrets.stackable.tech + accessModes: + - ReadWriteOnce + resources: + requests: + storage: "1" extraVolumeMounts: - - mountPath: /stackable/opensearch-dashboards/config/tls - name: tls - - mountPath: /stackable/opensearch-dashboards/config/opensearch_dashboards.yml - name: config # <14> - subPath: opensearch_dashboards.yml +- mountPath: /stackable/opensearch-dashboards/config/tls + name: tls +- mountPath: /stackable/opensearch-dashboards/config/opensearch_dashboards.yml + name: config # <14> + subPath: opensearch_dashboards.yml podSecurityContext: fsGroup: 1000 # <15> ---- diff --git a/docs/modules/opensearch/pages/usage-guide/opensearch-dashboards.adoc.j2 b/docs/modules/opensearch/pages/usage-guide/opensearch-dashboards.adoc.j2 index da964d3..99e6d7f 100644 --- a/docs/modules/opensearch/pages/usage-guide/opensearch-dashboards.adoc.j2 +++ b/docs/modules/opensearch/pages/usage-guide/opensearch-dashboards.adoc.j2 @@ -29,42 +29,42 @@ config: ssl: verificationMode: full # <8> certificateAuthorities: - - /stackable/opensearch-dashboards/config/tls/ca.crt # <9> + - /stackable/opensearch-dashboards/config/tls/ca.crt # <9> opensearch_security: cookie: secure: true # <10> extraEnvs: - - name: OPENSEARCH_HOSTS - valueFrom: - configMapKeyRef: - name: opensearch # <11> - key: OPENSEARCH_HOSTS - - name: OPENSEARCH_PASSWORD - valueFrom: - secretKeyRef: - name: opensearch-credentials - key: kibanaserver # <12> +- name: OPENSEARCH_HOSTS + valueFrom: + configMapKeyRef: + name: opensearch # <11> + key: OPENSEARCH_HOSTS +- name: OPENSEARCH_PASSWORD + valueFrom: + secretKeyRef: + name: opensearch-credentials + key: kibanaserver # <12> extraVolumes: - - name: tls # <13> - ephemeral: - volumeClaimTemplate: - metadata: - annotations: - secrets.stackable.tech/class: tls - secrets.stackable.tech/scope: service=opensearch-dashboards - spec: - storageClassName: secrets.stackable.tech - accessModes: - - ReadWriteOnce - resources: - requests: - storage: "1" +- name: tls # <13> + ephemeral: + volumeClaimTemplate: + metadata: + annotations: + secrets.stackable.tech/class: tls + secrets.stackable.tech/scope: service=opensearch-dashboards + spec: + storageClassName: secrets.stackable.tech + accessModes: + - ReadWriteOnce + resources: + requests: + storage: "1" extraVolumeMounts: - - mountPath: /stackable/opensearch-dashboards/config/tls - name: tls - - mountPath: /stackable/opensearch-dashboards/config/opensearch_dashboards.yml - name: config # <14> - subPath: opensearch_dashboards.yml +- mountPath: /stackable/opensearch-dashboards/config/tls + name: tls +- mountPath: /stackable/opensearch-dashboards/config/opensearch_dashboards.yml + name: config # <14> + subPath: opensearch_dashboards.yml podSecurityContext: fsGroup: 1000 # <15> ---- diff --git a/docs/modules/opensearch/pages/usage-guide/security.adoc b/docs/modules/opensearch/pages/usage-guide/security.adoc index d928d13..6ce055f 100644 --- a/docs/modules/opensearch/pages/usage-guide/security.adoc +++ b/docs/modules/opensearch/pages/usage-guide/security.adoc @@ -1,8 +1,152 @@ = Security -:description: Configure TLS encryption for OpenSearch with the Stackable Operator. +:description: Configure the OpenSearch security plugin with the Stackable Operator. + +Security in OpenSearch is managed by the OpenSearch security plugin. +The security plugin can be configured in `spec.clusterConfig.security` and is enabled by default: + +[source,yaml] +---- +--- +apiVersion: opensearch.stackable.tech/v1alpha1 +kind: OpenSearchCluster +metadata: + name: opensearch +spec: + clusterConfig: + security: + enabled: true +---- + +== Settings + +The configuration of the security plugin is stored in the security index. +When a new cluster is created, the security index is initialized from the following configuration files: + +* https://docs.opensearch.org/latest/security/configuration/yaml/#action_groupsyml[action_groups.yml{external-link-icon}^]: + user-defined action groups +* https://docs.opensearch.org/latest/security/configuration/yaml/#allowlistyml[allowlist.yml{external-link-icon}^]: + list of allowed HTTP endpoints +* https://docs.opensearch.org/latest/security/audit-logs/index/#settings-in-audityml[audit.yml{external-link-icon}^]: + settings for audit logging +* https://docs.opensearch.org/latest/security/configuration/configuration/[config.yml{external-link-icon}^]: + configuration of the security backend +* https://docs.opensearch.org/latest/security/configuration/yaml/#internal_usersyml[internal_users.yml{external-link-icon}^]: + the internal users database +* https://docs.opensearch.org/latest/security/configuration/yaml/#nodes_dnyml[nodes_dn.yml{external-link-icon}^]: + distinguished names (DNs) of nodes to allow communication between nodes and clusters +* https://docs.opensearch.org/latest/security/configuration/yaml/#rolesyml[roles.yml{external-link-icon}^]: + definition of roles in the security plugin +* https://docs.opensearch.org/latest/security/configuration/yaml/#roles_mappingyml[roles_mapping.yml{external-link-icon}^]: + Role mappings to users or backend roles +* https://docs.opensearch.org/latest/security/configuration/yaml/#tenantsyml[tenants.yml{external-link-icon}^]: + OpenSearch Dashboards tenants + +These configuration files can be specified in `spec.clusterConfig.security.settings`: + +[source,yaml] +---- +--- +apiVersion: opensearch.stackable.tech/v1alpha1 +kind: OpenSearchCluster +metadata: + name: opensearch +spec: + clusterConfig: + security: + settings: + actionGroups: ... + allowList: ... + audit: ... + config: ... + internalUsers: ... + nodesDn: ... + roles: ... + rolesMapping: ... + tenants: ... +---- + +If a setting is undefined, then a default configuration is deployed with no permissions. +Therefore, it is okay to only define some settings and leave the others unspecified. + +A setting can be defined either inline, via Secret or ConfigMap: + +[source,yaml] +---- +spec: + clusterConfig: + security: + settings: + config: + managedBy: API + content: + value: # defined inline + _meta: + type: config + config_version: 2 + ... + internalUsers: + managedBy: API + content: + valueFrom: + secretKeyRef: # defined via Secret + name: opensearch-security-config-secret + key: internal_users.yml + rolesMapping: + managedBy: API + content: + valueFrom: + configMapKeyRef: # defined via ConfigMap + name: opensearch-security-config-configmap + key: roles_mapping.yml +---- + +By default, the security settings are only used to initialize the security index: + +[source,yaml] +---- +spec: + clusterConfig: + security: + settings: + config: + managedBy: API + ... +---- + +Later changes are ignored, because usually, the index is managed via the https://docs.opensearch.org/latest/api-reference/security/configuration/index/[security configuration API{external-link-icon}^] and it should not be overridden by the operator. +However, if you prefer to manage some settings in the OpenSearchCluster specification, you can set `managedBy` to `operator`: + +[source,yaml] +---- +spec: + clusterConfig: + security: + settings: + config: + managedBy: operator + ... +---- + +[WARNING] +==== +It is possible to change `managedBy` from `API` to `operator` after the cluster was created, but be aware that all changes made via the API are lost. +==== + +All settings managed by the operator are updated by the role group defined in `spec.clusterConfig.security.managingRoleGroup` which defaults to `security-config`: + +[source,yaml] +---- +spec: + clusterConfig: + security: + managingRoleGroup: security-config +---- + +If this role group is not defined, it will be created by the operator. == TLS +TLS is also managed by the OpenSearch security plugin, therefore TLS is only available if the security plugin was not disabled. The internal and client communication at the REST API can be encrypted with TLS. This requires the xref:secret-operator:index.adoc[Secret Operator] to be running in the Kubernetes cluster providing certificates. The used certificates can be changed in a cluster-wide config and are configured using xref:secret-operator:secretclass.adoc[SecretClasses]. @@ -36,5 +180,34 @@ Defaults to the `tls` SecretClass and can't be disabled. <3> The lifetime for autoTls certificates generated by the secret operator. Only a lifetime up to the `maxCertificateLifetime` setting in the SecretClass is applied. -Important: The operator sets the configuration `plugins.security.nodes_dn` to `["CN=generated certificate for pod"]` which provides weak authentication between nodes. +[WARNING] +==== +The operator sets the configuration `plugins.security.nodes_dn` to `["CN=generated certificate for pod"]` which provides weak authentication between nodes. If you want to increase security and use certificates which identify the OpenSearch nodes specifically, you must also adapt the `plugins.security.nodes_dn` setting via configOverrides. +==== + +== Disabling security + +The OpenSearch security plugin can be disabled as follows: + +[source,yaml] +---- +--- +apiVersion: opensearch.stackable.tech/v1alpha1 +kind: OpenSearchCluster +metadata: + name: opensearch +spec: + clusterConfig: + security: + enabled: false +---- + +All other security settings as well as the TLS settings are then ignored. + +[WARNING] +==== +If the security plugin was enabled before disabling it, then the security index is exposed to the public. +==== + +OpenSearch Dashboards require an enabled security plugin. diff --git a/extra/crds.yaml b/extra/crds.yaml index e0a8d60..e1cc34f 100644 --- a/extra/crds.yaml +++ b/extra/crds.yaml @@ -29,6 +29,82 @@ spec: clusterConfig: default: keystore: [] + security: + enabled: true + managingRoleGroup: security-config + settings: + actionGroups: + content: + value: + _meta: + config_version: 2 + type: actiongroups + managedBy: API + allowList: + content: + value: + _meta: + config_version: 2 + type: allowlist + config: + enabled: false + managedBy: API + audit: + content: + value: + _meta: + config_version: 2 + type: audit + config: + enabled: false + managedBy: API + config: + content: + value: + _meta: + config_version: 2 + type: config + config: + dynamic: + authc: {} + authz: {} + http: {} + managedBy: API + internalUsers: + content: + value: + _meta: + config_version: 2 + type: internalusers + managedBy: API + nodesDn: + content: + value: + _meta: + config_version: 2 + type: nodesdn + managedBy: API + roles: + content: + value: + _meta: + config_version: 2 + type: roles + managedBy: API + rolesMapping: + content: + value: + _meta: + config_version: 2 + type: rolesmapping + managedBy: API + tenants: + content: + value: + _meta: + config_version: 2 + type: tenants + managedBy: API tls: internalSecretClass: tls serverSecretClass: tls @@ -68,11 +144,987 @@ spec: - secretKeyRef type: object type: array + security: + default: + enabled: true + managingRoleGroup: security-config + settings: + actionGroups: + content: + value: + _meta: + config_version: 2 + type: actiongroups + managedBy: API + allowList: + content: + value: + _meta: + config_version: 2 + type: allowlist + config: + enabled: false + managedBy: API + audit: + content: + value: + _meta: + config_version: 2 + type: audit + config: + enabled: false + managedBy: API + config: + content: + value: + _meta: + config_version: 2 + type: config + config: + dynamic: + authc: {} + authz: {} + http: {} + managedBy: API + internalUsers: + content: + value: + _meta: + config_version: 2 + type: internalusers + managedBy: API + nodesDn: + content: + value: + _meta: + config_version: 2 + type: nodesdn + managedBy: API + roles: + content: + value: + _meta: + config_version: 2 + type: roles + managedBy: API + rolesMapping: + content: + value: + _meta: + config_version: 2 + type: rolesmapping + managedBy: API + tenants: + content: + value: + _meta: + config_version: 2 + type: tenants + managedBy: API + description: Configuration of the OpenSearch security plugin + properties: + enabled: + default: true + description: |- + Whether to enable the OpenSearch security plugin + + Disabling the security plugin also disables TLS and exposes the security index if it + exists. + type: boolean + managingRoleGroup: + default: security-config + description: The role group that updates the security index if any setting is managed by the operator. + maxLength: 16 + minLength: 1 + type: string + settings: + default: + actionGroups: + content: + value: + _meta: + config_version: 2 + type: actiongroups + managedBy: API + allowList: + content: + value: + _meta: + config_version: 2 + type: allowlist + config: + enabled: false + managedBy: API + audit: + content: + value: + _meta: + config_version: 2 + type: audit + config: + enabled: false + managedBy: API + config: + content: + value: + _meta: + config_version: 2 + type: config + config: + dynamic: + authc: {} + authz: {} + http: {} + managedBy: API + internalUsers: + content: + value: + _meta: + config_version: 2 + type: internalusers + managedBy: API + nodesDn: + content: + value: + _meta: + config_version: 2 + type: nodesdn + managedBy: API + roles: + content: + value: + _meta: + config_version: 2 + type: roles + managedBy: API + rolesMapping: + content: + value: + _meta: + config_version: 2 + type: rolesmapping + managedBy: API + tenants: + content: + value: + _meta: + config_version: 2 + type: tenants + managedBy: API + description: Settings for the OpenSearch security plugin + properties: + actionGroups: + default: + content: + value: + _meta: + config_version: 2 + type: actiongroups + managedBy: API + description: |- + User-defined action groups + + see + properties: + content: + description: The content of the security configuration file + oneOf: + - required: + - value + - required: + - valueFrom + properties: + value: + description: Security configuration file content defined inline + type: object + x-kubernetes-preserve-unknown-fields: true + valueFrom: + description: Security configuration file content ingested from a ConfigMap or Secret + oneOf: + - required: + - configMapKeyRef + - required: + - secretKeyRef + properties: + configMapKeyRef: + description: Reference to a key in a ConfigMap + properties: + key: + description: Key in the ConfigMap that contains the value + maxLength: 253 + minLength: 1 + pattern: ^[-._a-zA-Z0-9]+$ + type: string + name: + description: Name of the ConfigMap + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + required: + - key + - name + type: object + secretKeyRef: + description: Reference to a key in a Secret + properties: + key: + description: Key in the Secret that contains the value + maxLength: 253 + minLength: 1 + pattern: ^[-._a-zA-Z0-9]+$ + type: string + name: + description: Name of the Secret + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + required: + - key + - name + type: object + type: object + type: object + managedBy: + description: |- + Whether this configuration should only be applied initially and afterwards be managed + via the "API", or managed all the time by the "operator". + + If this configuration is changed later from "API" to "operator", then the changes made + via the API are overridden. + enum: + - API + - operator + type: string + required: + - content + - managedBy + type: object + allowList: + default: + content: + value: + _meta: + config_version: 2 + type: allowlist + config: + enabled: false + managedBy: API + description: |- + List of allowed HTTP endpoints + + see + properties: + content: + description: The content of the security configuration file + oneOf: + - required: + - value + - required: + - valueFrom + properties: + value: + description: Security configuration file content defined inline + type: object + x-kubernetes-preserve-unknown-fields: true + valueFrom: + description: Security configuration file content ingested from a ConfigMap or Secret + oneOf: + - required: + - configMapKeyRef + - required: + - secretKeyRef + properties: + configMapKeyRef: + description: Reference to a key in a ConfigMap + properties: + key: + description: Key in the ConfigMap that contains the value + maxLength: 253 + minLength: 1 + pattern: ^[-._a-zA-Z0-9]+$ + type: string + name: + description: Name of the ConfigMap + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + required: + - key + - name + type: object + secretKeyRef: + description: Reference to a key in a Secret + properties: + key: + description: Key in the Secret that contains the value + maxLength: 253 + minLength: 1 + pattern: ^[-._a-zA-Z0-9]+$ + type: string + name: + description: Name of the Secret + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + required: + - key + - name + type: object + type: object + type: object + managedBy: + description: |- + Whether this configuration should only be applied initially and afterwards be managed + via the "API", or managed all the time by the "operator". + + If this configuration is changed later from "API" to "operator", then the changes made + via the API are overridden. + enum: + - API + - operator + type: string + required: + - content + - managedBy + type: object + audit: + default: + content: + value: + _meta: + config_version: 2 + type: audit + config: + enabled: false + managedBy: API + description: |- + Settings for audit logging + + see + + properties: + content: + description: The content of the security configuration file + oneOf: + - required: + - value + - required: + - valueFrom + properties: + value: + description: Security configuration file content defined inline + type: object + x-kubernetes-preserve-unknown-fields: true + valueFrom: + description: Security configuration file content ingested from a ConfigMap or Secret + oneOf: + - required: + - configMapKeyRef + - required: + - secretKeyRef + properties: + configMapKeyRef: + description: Reference to a key in a ConfigMap + properties: + key: + description: Key in the ConfigMap that contains the value + maxLength: 253 + minLength: 1 + pattern: ^[-._a-zA-Z0-9]+$ + type: string + name: + description: Name of the ConfigMap + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + required: + - key + - name + type: object + secretKeyRef: + description: Reference to a key in a Secret + properties: + key: + description: Key in the Secret that contains the value + maxLength: 253 + minLength: 1 + pattern: ^[-._a-zA-Z0-9]+$ + type: string + name: + description: Name of the Secret + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + required: + - key + - name + type: object + type: object + type: object + managedBy: + description: |- + Whether this configuration should only be applied initially and afterwards be managed + via the "API", or managed all the time by the "operator". + + If this configuration is changed later from "API" to "operator", then the changes made + via the API are overridden. + enum: + - API + - operator + type: string + required: + - content + - managedBy + type: object + config: + default: + content: + value: + _meta: + config_version: 2 + type: config + config: + dynamic: + authc: {} + authz: {} + http: {} + managedBy: API + description: |- + Configuration of the security backend + + see + properties: + content: + description: The content of the security configuration file + oneOf: + - required: + - value + - required: + - valueFrom + properties: + value: + description: Security configuration file content defined inline + type: object + x-kubernetes-preserve-unknown-fields: true + valueFrom: + description: Security configuration file content ingested from a ConfigMap or Secret + oneOf: + - required: + - configMapKeyRef + - required: + - secretKeyRef + properties: + configMapKeyRef: + description: Reference to a key in a ConfigMap + properties: + key: + description: Key in the ConfigMap that contains the value + maxLength: 253 + minLength: 1 + pattern: ^[-._a-zA-Z0-9]+$ + type: string + name: + description: Name of the ConfigMap + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + required: + - key + - name + type: object + secretKeyRef: + description: Reference to a key in a Secret + properties: + key: + description: Key in the Secret that contains the value + maxLength: 253 + minLength: 1 + pattern: ^[-._a-zA-Z0-9]+$ + type: string + name: + description: Name of the Secret + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + required: + - key + - name + type: object + type: object + type: object + managedBy: + description: |- + Whether this configuration should only be applied initially and afterwards be managed + via the "API", or managed all the time by the "operator". + + If this configuration is changed later from "API" to "operator", then the changes made + via the API are overridden. + enum: + - API + - operator + type: string + required: + - content + - managedBy + type: object + internalUsers: + default: + content: + value: + _meta: + config_version: 2 + type: internalusers + managedBy: API + description: |- + The internal user database + + see + properties: + content: + description: The content of the security configuration file + oneOf: + - required: + - value + - required: + - valueFrom + properties: + value: + description: Security configuration file content defined inline + type: object + x-kubernetes-preserve-unknown-fields: true + valueFrom: + description: Security configuration file content ingested from a ConfigMap or Secret + oneOf: + - required: + - configMapKeyRef + - required: + - secretKeyRef + properties: + configMapKeyRef: + description: Reference to a key in a ConfigMap + properties: + key: + description: Key in the ConfigMap that contains the value + maxLength: 253 + minLength: 1 + pattern: ^[-._a-zA-Z0-9]+$ + type: string + name: + description: Name of the ConfigMap + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + required: + - key + - name + type: object + secretKeyRef: + description: Reference to a key in a Secret + properties: + key: + description: Key in the Secret that contains the value + maxLength: 253 + minLength: 1 + pattern: ^[-._a-zA-Z0-9]+$ + type: string + name: + description: Name of the Secret + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + required: + - key + - name + type: object + type: object + type: object + managedBy: + description: |- + Whether this configuration should only be applied initially and afterwards be managed + via the "API", or managed all the time by the "operator". + + If this configuration is changed later from "API" to "operator", then the changes made + via the API are overridden. + enum: + - API + - operator + type: string + required: + - content + - managedBy + type: object + nodesDn: + default: + content: + value: + _meta: + config_version: 2 + type: nodesdn + managedBy: API + description: |- + Distinguished names (DNs) of nodes to allow communication between nodes and clusters + + see + properties: + content: + description: The content of the security configuration file + oneOf: + - required: + - value + - required: + - valueFrom + properties: + value: + description: Security configuration file content defined inline + type: object + x-kubernetes-preserve-unknown-fields: true + valueFrom: + description: Security configuration file content ingested from a ConfigMap or Secret + oneOf: + - required: + - configMapKeyRef + - required: + - secretKeyRef + properties: + configMapKeyRef: + description: Reference to a key in a ConfigMap + properties: + key: + description: Key in the ConfigMap that contains the value + maxLength: 253 + minLength: 1 + pattern: ^[-._a-zA-Z0-9]+$ + type: string + name: + description: Name of the ConfigMap + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + required: + - key + - name + type: object + secretKeyRef: + description: Reference to a key in a Secret + properties: + key: + description: Key in the Secret that contains the value + maxLength: 253 + minLength: 1 + pattern: ^[-._a-zA-Z0-9]+$ + type: string + name: + description: Name of the Secret + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + required: + - key + - name + type: object + type: object + type: object + managedBy: + description: |- + Whether this configuration should only be applied initially and afterwards be managed + via the "API", or managed all the time by the "operator". + + If this configuration is changed later from "API" to "operator", then the changes made + via the API are overridden. + enum: + - API + - operator + type: string + required: + - content + - managedBy + type: object + roles: + default: + content: + value: + _meta: + config_version: 2 + type: roles + managedBy: API + description: |- + Definition of roles in the security plugin + + see + properties: + content: + description: The content of the security configuration file + oneOf: + - required: + - value + - required: + - valueFrom + properties: + value: + description: Security configuration file content defined inline + type: object + x-kubernetes-preserve-unknown-fields: true + valueFrom: + description: Security configuration file content ingested from a ConfigMap or Secret + oneOf: + - required: + - configMapKeyRef + - required: + - secretKeyRef + properties: + configMapKeyRef: + description: Reference to a key in a ConfigMap + properties: + key: + description: Key in the ConfigMap that contains the value + maxLength: 253 + minLength: 1 + pattern: ^[-._a-zA-Z0-9]+$ + type: string + name: + description: Name of the ConfigMap + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + required: + - key + - name + type: object + secretKeyRef: + description: Reference to a key in a Secret + properties: + key: + description: Key in the Secret that contains the value + maxLength: 253 + minLength: 1 + pattern: ^[-._a-zA-Z0-9]+$ + type: string + name: + description: Name of the Secret + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + required: + - key + - name + type: object + type: object + type: object + managedBy: + description: |- + Whether this configuration should only be applied initially and afterwards be managed + via the "API", or managed all the time by the "operator". + + If this configuration is changed later from "API" to "operator", then the changes made + via the API are overridden. + enum: + - API + - operator + type: string + required: + - content + - managedBy + type: object + rolesMapping: + default: + content: + value: + _meta: + config_version: 2 + type: rolesmapping + managedBy: API + description: |- + Role mappings to users or backend roles + + see + properties: + content: + description: The content of the security configuration file + oneOf: + - required: + - value + - required: + - valueFrom + properties: + value: + description: Security configuration file content defined inline + type: object + x-kubernetes-preserve-unknown-fields: true + valueFrom: + description: Security configuration file content ingested from a ConfigMap or Secret + oneOf: + - required: + - configMapKeyRef + - required: + - secretKeyRef + properties: + configMapKeyRef: + description: Reference to a key in a ConfigMap + properties: + key: + description: Key in the ConfigMap that contains the value + maxLength: 253 + minLength: 1 + pattern: ^[-._a-zA-Z0-9]+$ + type: string + name: + description: Name of the ConfigMap + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + required: + - key + - name + type: object + secretKeyRef: + description: Reference to a key in a Secret + properties: + key: + description: Key in the Secret that contains the value + maxLength: 253 + minLength: 1 + pattern: ^[-._a-zA-Z0-9]+$ + type: string + name: + description: Name of the Secret + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + required: + - key + - name + type: object + type: object + type: object + managedBy: + description: |- + Whether this configuration should only be applied initially and afterwards be managed + via the "API", or managed all the time by the "operator". + + If this configuration is changed later from "API" to "operator", then the changes made + via the API are overridden. + enum: + - API + - operator + type: string + required: + - content + - managedBy + type: object + tenants: + default: + content: + value: + _meta: + config_version: 2 + type: tenants + managedBy: API + description: |- + OpenSearch Dashboards tenants + + see + properties: + content: + description: The content of the security configuration file + oneOf: + - required: + - value + - required: + - valueFrom + properties: + value: + description: Security configuration file content defined inline + type: object + x-kubernetes-preserve-unknown-fields: true + valueFrom: + description: Security configuration file content ingested from a ConfigMap or Secret + oneOf: + - required: + - configMapKeyRef + - required: + - secretKeyRef + properties: + configMapKeyRef: + description: Reference to a key in a ConfigMap + properties: + key: + description: Key in the ConfigMap that contains the value + maxLength: 253 + minLength: 1 + pattern: ^[-._a-zA-Z0-9]+$ + type: string + name: + description: Name of the ConfigMap + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + required: + - key + - name + type: object + secretKeyRef: + description: Reference to a key in a Secret + properties: + key: + description: Key in the Secret that contains the value + maxLength: 253 + minLength: 1 + pattern: ^[-._a-zA-Z0-9]+$ + type: string + name: + description: Name of the Secret + maxLength: 253 + minLength: 1 + pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ + type: string + required: + - key + - name + type: object + type: object + type: object + managedBy: + description: |- + Whether this configuration should only be applied initially and afterwards be managed + via the "API", or managed all the time by the "operator". + + If this configuration is changed later from "API" to "operator", then the changes made + via the API are overridden. + enum: + - API + - operator + type: string + required: + - content + - managedBy + type: object + type: object + type: object tls: default: internalSecretClass: tls serverSecretClass: tls - description: TLS configuration options for the server (REST API) and internal communication (transport). + description: |- + TLS configuration options for the server (REST API) and internal communication (transport). + + This configuration is only effective if the OpenSearch security plugin is not disabled. properties: internalSecretClass: default: tls diff --git a/rust/operator-binary/src/controller.rs b/rust/operator-binary/src/controller.rs index f85223b..3859380 100644 --- a/rust/operator-binary/src/controller.rs +++ b/rust/operator-binary/src/controller.rs @@ -3,7 +3,12 @@ //! The cluster specification is validated, Kubernetes resource specifications are created and //! applied and the cluster status is updated. -use std::{collections::BTreeMap, marker::PhantomData, str::FromStr, sync::Arc}; +use std::{ + collections::{BTreeMap, BTreeSet}, + marker::PhantomData, + str::FromStr, + sync::Arc, +}; use apply::Applier; use build::build; @@ -26,19 +31,20 @@ use stackable_operator::{ logging::controller::ReconcilerError, shared::time::Duration, }; -use strum::{EnumDiscriminants, IntoStaticStr}; +use strum::{Display, EnumDiscriminants, EnumIter, IntoStaticStr}; use update_status::update_status; use validate::validate; use crate::{ - crd::{NodeRoles, v1alpha1}, + controller::preprocess::preprocess, + crd::v1alpha1, framework::{ HasName, HasUid, NameIsValidLabelValue, product_logging::framework::{ValidatedContainerLogConfigChoice, VectorContainerLogConfig}, role_utils::{GenericProductSpecificCommonConfig, RoleGroupConfig}, types::{ common::Port, - kubernetes::{Hostname, ListenerClassName, NamespaceName, Uid}, + kubernetes::{Hostname, ListenerClassName, NamespaceName, SecretClassName, Uid}, operator::{ ClusterName, ControllerName, OperatorName, ProductName, ProductVersion, RoleGroupName, RoleName, @@ -50,6 +56,7 @@ use crate::{ mod apply; mod build; mod dereference; +mod preprocess; mod update_status; mod validate; @@ -157,7 +164,7 @@ pub struct ValidatedOpenSearchConfig { pub discovery_service_exposed: bool, pub listener_class: ListenerClassName, pub logging: ValidatedLogging, - pub node_roles: NodeRoles, + pub node_roles: ValidatedNodeRoles, pub requested_secret_lifetime: Duration, pub resources: OpenSearchNodeResources, pub termination_grace_period_seconds: i64, @@ -176,6 +183,45 @@ impl ValidatedLogging { } } +/// Set of validated node roles +/// +/// An empty set specifies a coordinating only node. +type ValidatedNodeRoles = BTreeSet; + +/// Validated node role +#[derive(Clone, Copy, Debug, Display, EnumIter, Eq, PartialEq, PartialOrd, Ord)] +#[strum(serialize_all = "snake_case")] +pub enum ValidatedNodeRole { + ClusterManager, + Data, + Ingest, + RemoteClusterClient, + Search, + Warm, +} + +/// Validated security configuration +#[derive(Clone, Debug, PartialEq)] +pub enum ValidatedSecurity { + /// At least one security setting is managed by the operator + ManagedByOperator { + managing_role_group: RoleGroupName, + settings: v1alpha1::SecuritySettings, + tls_server_secret_class: SecretClassName, + tls_internal_secret_class: SecretClassName, + }, + + /// All security settings are managed by the API + ManagedByApi { + settings: v1alpha1::SecuritySettings, + tls_server_secret_class: Option, + tls_internal_secret_class: SecretClassName, + }, + + /// The OpenSearch security plugin is disabled. + Disabled, +} + #[derive(Clone, Debug, PartialEq)] pub struct ValidatedDiscoveryEndpoint { pub hostname: Hostname, @@ -200,7 +246,7 @@ pub struct ValidatedCluster { pub uid: Uid, pub role_config: v1alpha1::OpenSearchRoleConfig, pub role_group_configs: BTreeMap, - pub tls_config: v1alpha1::OpenSearchTls, + pub security: ValidatedSecurity, pub keystores: Vec, pub discovery_endpoint: Option, } @@ -215,7 +261,7 @@ impl ValidatedCluster { uid: impl Into, role_config: v1alpha1::OpenSearchRoleConfig, role_group_configs: BTreeMap, - tls_config: v1alpha1::OpenSearchTls, + security: ValidatedSecurity, keystores: Vec, discovery_endpoint: Option, ) -> Self { @@ -234,7 +280,7 @@ impl ValidatedCluster { uid, role_config, role_group_configs, - tls_config, + security, keystores, discovery_endpoint, } @@ -261,7 +307,7 @@ impl ValidatedCluster { /// Returns all role-group configurations which contain the given node role pub fn role_group_configs_filtered_by_node_role( &self, - node_role: &v1alpha1::NodeRole, + node_role: &ValidatedNodeRole, ) -> BTreeMap { self.role_group_configs .clone() @@ -269,6 +315,20 @@ impl ValidatedCluster { .filter(|c| c.1.config.node_roles.contains(node_role)) .collect() } + + /// Whether security is enabled and a server TLS class is defined or not. + pub fn is_server_tls_enabled(&self) -> bool { + matches!( + self.security, + ValidatedSecurity::ManagedByApi { + tls_server_secret_class: Some(_), + .. + } | ValidatedSecurity::ManagedByOperator { + tls_server_secret_class: _, + .. + } + ) + } } impl HasName for ValidatedCluster { @@ -355,10 +415,14 @@ pub fn error_policy( /// Reconcile function of the OpenSearchCluster controller /// /// The reconcile function performs the following steps: -/// 1. Validate the given cluster specification and return a [`ValidatedCluster`] if successful. -/// 2. Build Kubernetes resource specifications from the validated cluster. -/// 3. Apply the Kubernetes resource specifications -/// 4. Update the cluster status +/// 1. Dereference objects which are referenced in the OpenSearchCluster. +/// 2. Preprocess the OpenSearchCluster specification and add configurations that the user is +/// allowed to leave out. +/// 3. Validate the preprocessed cluster specification and the dereferenced objects and return a +/// [`ValidatedCluster`] if successful. +/// 4. Build Kubernetes resource specifications from the validated cluster. +/// 5. Apply the Kubernetes resource specifications +/// 6. Update the cluster status pub async fn reconcile( object: Arc>, context: Arc, @@ -369,22 +433,27 @@ pub async fn reconcile( .0 .as_ref() .map_err(stackable_operator::kube::core::error_boundary::InvalidObject::clone) - .context(DeserializeClusterDefinitionSnafu)?; + .context(DeserializeClusterDefinitionSnafu)? + .clone(); // dereference (client required) - let dereferenced_objects = dereference(&context.client, cluster) + let dereferenced_objects = dereference(&context.client, &cluster) .await .context(DereferenceSnafu)?; + // preprocess (no client required) + let preprocessed_cluster = preprocess(cluster); + // validate (no client required) - let validated_cluster = - validate(&context.names, cluster, &dereferenced_objects).context(ValidateClusterSnafu)?; + let validated_cluster = validate(&context.names, &preprocessed_cluster, &dereferenced_objects) + .context(ValidateClusterSnafu)?; // build (no client required; infallible) let prepared_resources = build(&context.names, validated_cluster.clone()); // apply (client required) - let apply_strategy = ClusterResourceApplyStrategy::from(&cluster.spec.cluster_operation); + let apply_strategy = + ClusterResourceApplyStrategy::from(&preprocessed_cluster.spec.cluster_operation); let applied_resources = Applier::new( &context.client, &context.names, @@ -392,7 +461,7 @@ pub async fn reconcile( &validated_cluster.namespace, &validated_cluster.uid, apply_strategy, - &cluster.spec.object_overrides, + &preprocessed_cluster.spec.object_overrides, ) .apply(prepared_resources) .await @@ -401,9 +470,14 @@ pub async fn reconcile( // not necessary in this controller: create discovery ConfigMap based on the applied resources (client required) // update status (client required) - update_status(&context.client, &context.names, cluster, applied_resources) - .await - .context(UpdateStatusSnafu)?; + update_status( + &context.client, + &context.names, + &preprocessed_cluster, + applied_resources, + ) + .await + .context(UpdateStatusSnafu)?; Ok(Action::await_change()) } @@ -429,14 +503,17 @@ mod tests { use super::{Context, OpenSearchRoleGroupConfig, ValidatedCluster, ValidatedLogging}; use crate::{ - controller::{OpenSearchNodeResources, ValidatedOpenSearchConfig}, - crd::{NodeRoles, v1alpha1}, + controller::{ + OpenSearchNodeResources, ValidatedNodeRole, ValidatedNodeRoles, + ValidatedOpenSearchConfig, ValidatedSecurity, + }, + crd::v1alpha1, framework::{ builder::pod::container::EnvVarSet, product_logging::framework::ValidatedContainerLogConfigChoice, role_utils::GenericProductSpecificCommonConfig, types::{ - kubernetes::{ListenerClassName, NamespaceName}, + kubernetes::{ListenerClassName, NamespaceName, SecretClassName}, operator::{ClusterName, OperatorName, ProductVersion, RoleGroupName}, }, }, @@ -481,10 +558,10 @@ mod tests { RoleGroupName::from_str_unsafe("data1"), role_group_config( 4, - &[ - v1alpha1::NodeRole::Ingest, - v1alpha1::NodeRole::Data, - v1alpha1::NodeRole::RemoteClusterClient, + [ + ValidatedNodeRole::Ingest, + ValidatedNodeRole::Data, + ValidatedNodeRole::RemoteClusterClient, ], ), ), @@ -492,15 +569,15 @@ mod tests { RoleGroupName::from_str_unsafe("data2"), role_group_config( 6, - &[ - v1alpha1::NodeRole::Ingest, - v1alpha1::NodeRole::Data, - v1alpha1::NodeRole::RemoteClusterClient, + [ + ValidatedNodeRole::Ingest, + ValidatedNodeRole::Data, + ValidatedNodeRole::RemoteClusterClient, ], ), ), ]), - validated_cluster.role_group_configs_filtered_by_node_role(&v1alpha1::NodeRole::Data) + validated_cluster.role_group_configs_filtered_by_node_role(&ValidatedNodeRole::Data) ); } @@ -522,20 +599,20 @@ mod tests { [ ( RoleGroupName::from_str_unsafe("coordinating"), - role_group_config(5, &[v1alpha1::NodeRole::CoordinatingOnly]), + role_group_config(5, []), ), ( RoleGroupName::from_str_unsafe("cluster-manager"), - role_group_config(3, &[v1alpha1::NodeRole::ClusterManager]), + role_group_config(3, [ValidatedNodeRole::ClusterManager]), ), ( RoleGroupName::from_str_unsafe("data1"), role_group_config( 4, - &[ - v1alpha1::NodeRole::Ingest, - v1alpha1::NodeRole::Data, - v1alpha1::NodeRole::RemoteClusterClient, + [ + ValidatedNodeRole::Ingest, + ValidatedNodeRole::Data, + ValidatedNodeRole::RemoteClusterClient, ], ), ), @@ -543,16 +620,20 @@ mod tests { RoleGroupName::from_str_unsafe("data2"), role_group_config( 6, - &[ - v1alpha1::NodeRole::Ingest, - v1alpha1::NodeRole::Data, - v1alpha1::NodeRole::RemoteClusterClient, + [ + ValidatedNodeRole::Ingest, + ValidatedNodeRole::Data, + ValidatedNodeRole::RemoteClusterClient, ], ), ), ] .into(), - v1alpha1::OpenSearchTls::default(), + ValidatedSecurity::ManagedByApi { + settings: v1alpha1::SecuritySettings::default(), + tls_server_secret_class: None, + tls_internal_secret_class: SecretClassName::from_str_unsafe("tls"), + }, vec![], None, ) @@ -560,7 +641,7 @@ mod tests { fn role_group_config( replicas: u16, - node_roles: &[v1alpha1::NodeRole], + node_roles: impl Into, ) -> OpenSearchRoleGroupConfig { OpenSearchRoleGroupConfig { replicas, @@ -574,7 +655,7 @@ mod tests { ), vector_container: None, }, - node_roles: NodeRoles(node_roles.to_vec()), + node_roles: node_roles.into(), requested_secret_lifetime: Duration::from_str("1d") .expect("should be a valid duration"), resources: OpenSearchNodeResources::default(), diff --git a/rust/operator-binary/src/controller/build.rs b/rust/operator-binary/src/controller/build.rs index 9055050..5be50f3 100644 --- a/rust/operator-binary/src/controller/build.rs +++ b/rust/operator-binary/src/controller/build.rs @@ -82,9 +82,9 @@ mod tests { controller::{ ContextNames, OpenSearchNodeResources, OpenSearchRoleGroupConfig, ValidatedCluster, ValidatedContainerLogConfigChoice, ValidatedDiscoveryEndpoint, ValidatedLogging, - ValidatedOpenSearchConfig, + ValidatedNodeRole, ValidatedNodeRoles, ValidatedOpenSearchConfig, ValidatedSecurity, }, - crd::{NodeRoles, v1alpha1}, + crd::v1alpha1, framework::{ builder::pod::container::EnvVarSet, role_utils::GenericProductSpecificCommonConfig, @@ -190,26 +190,26 @@ mod tests { [ ( RoleGroupName::from_str_unsafe("coordinating"), - role_group_config(5, &[v1alpha1::NodeRole::CoordinatingOnly]), + role_group_config(5, []), ), ( RoleGroupName::from_str_unsafe("cluster-manager"), - role_group_config(3, &[v1alpha1::NodeRole::ClusterManager]), + role_group_config(3, [ValidatedNodeRole::ClusterManager]), ), ( RoleGroupName::from_str_unsafe("data"), role_group_config( 8, - &[ - v1alpha1::NodeRole::Ingest, - v1alpha1::NodeRole::Data, - v1alpha1::NodeRole::RemoteClusterClient, + [ + ValidatedNodeRole::Ingest, + ValidatedNodeRole::Data, + ValidatedNodeRole::RemoteClusterClient, ], ), ), ] .into(), - v1alpha1::OpenSearchTls::default(), + ValidatedSecurity::Disabled, vec![], Some(ValidatedDiscoveryEndpoint { hostname: Hostname::from_str_unsafe("1.2.3.4"), @@ -220,7 +220,7 @@ mod tests { fn role_group_config( replicas: u16, - node_roles: &[v1alpha1::NodeRole], + node_roles: impl Into, ) -> OpenSearchRoleGroupConfig { OpenSearchRoleGroupConfig { replicas, @@ -234,7 +234,7 @@ mod tests { ), vector_container: None, }, - node_roles: NodeRoles(node_roles.to_vec()), + node_roles: node_roles.into(), requested_secret_lifetime: Duration::from_str("1d") .expect("should be a valid duration"), resources: OpenSearchNodeResources::default(), diff --git a/rust/operator-binary/src/controller/build/node_config.rs b/rust/operator-binary/src/controller/build/node_config.rs index de266ce..26591fd 100644 --- a/rust/operator-binary/src/controller/build/node_config.rs +++ b/rust/operator-binary/src/controller/build/node_config.rs @@ -1,15 +1,17 @@ //! Configuration of an OpenSearch node -use std::str::FromStr; - use serde_json::{Value, json}; use stackable_operator::{ builder::pod::container::FieldPathEnvVar, commons::networking::DomainName, }; +use tracing::warn; use super::ValidatedCluster; use crate::{ - controller::OpenSearchRoleGroupConfig, + controller::{ + OpenSearchRoleGroupConfig, ValidatedNodeRole, + build::role_group_builder::RoleGroupSecurityMode, + }, crd::v1alpha1, framework::{ builder::pod::container::{EnvVarName, EnvVarSet}, @@ -67,11 +69,25 @@ const CONFIG_OPTION_NODE_ROLES: &str = "node.roles"; /// Defines the path for the logs /// OpenSearch grants the required access rights, see -/// https://github.com/opensearch-project/OpenSearch/blob/3.4.0/server/src/main/java/org/opensearch/bootstrap/Security.java#L369 +/// /// The permissions "write" and "delete" are required for the log file rollover. /// Type: string const CONFIG_OPTION_PATH_LOGS: &str = "path.logs"; +/// If this is set to true, the OpenSearch security plugin will automatically initialize the +/// configuration index with the files in the config directory if the index does not exist. +/// Type: boolean +const CONFIG_OPTION_PLUGINS_SECURITY_ALLOW_DEFAULT_INIT_SECURITYINDEX: &str = + "plugins.security.allow_default_init_securityindex"; + +/// Defines the DNs of certificates to which admin privileges should be assigned. +/// Type: (comma-separated) list of strings +const CONFIG_OPTION_PLUGINS_SECURITY_AUTHCZ_ADMIN_DN: &str = "plugins.security.authcz.admin_dn"; + +/// Whether to disable the security plugin +/// Type: boolean +const CONFIG_OPTION_PLUGINS_SECURITY_DISABLED: &str = "plugins.security.disabled"; + /// Specifies a list of distinguished names (DNs) that denote the other nodes in the cluster. /// Type: (comma-separated) list of strings const CONFIG_OPTION_PLUGINS_SECURITY_NODES_DN: &str = "plugins.security.nodes_dn"; @@ -127,6 +143,7 @@ pub struct NodeConfig { cluster: ValidatedCluster, role_group_name: RoleGroupName, role_group_config: OpenSearchRoleGroupConfig, + role_group_security_mode: RoleGroupSecurityMode, pub seed_nodes_service_name: ServiceName, cluster_domain_name: DomainName, headless_service_name: ServiceName, @@ -139,6 +156,7 @@ impl NodeConfig { cluster: ValidatedCluster, role_group_name: RoleGroupName, role_group_config: OpenSearchRoleGroupConfig, + role_group_security_mode: RoleGroupSecurityMode, seed_nodes_service_name: ServiceName, cluster_domain_name: DomainName, headless_service_name: ServiceName, @@ -147,6 +165,7 @@ impl NodeConfig { cluster, role_group_name, role_group_config, + role_group_security_mode, seed_nodes_service_name, cluster_domain_name, headless_service_name, @@ -170,7 +189,12 @@ impl NodeConfig { .into_iter() .flatten() { - config.insert(setting.to_owned(), json!(value)); + let old_value = config.insert(setting.to_owned(), json!(value)); + if let Some(old_value) = old_value { + warn!( + "configOverrides: Configuration setting {setting:?} changed from {old_value} to {value:?}." + ); + } } // Ensure a deterministic result @@ -217,33 +241,70 @@ impl NodeConfig { )), ); + match self.role_group_security_mode { + RoleGroupSecurityMode::Initializing { .. } => { + config.insert( + CONFIG_OPTION_PLUGINS_SECURITY_ALLOW_DEFAULT_INIT_SECURITYINDEX.to_owned(), + json!(true), + ); + } + RoleGroupSecurityMode::Managing { .. } + | RoleGroupSecurityMode::Participating { .. } => { + config.insert( + CONFIG_OPTION_PLUGINS_SECURITY_AUTHCZ_ADMIN_DN.to_owned(), + json!(self.super_admin_dn()), + ); + } + RoleGroupSecurityMode::Disabled => { + config.insert( + CONFIG_OPTION_PLUGINS_SECURITY_DISABLED.to_owned(), + json!(true), + ); + } + }; + config } + /// Distinguished name (DN) of the super admin certificate + pub fn super_admin_dn(&self) -> String { + // The common name field is limited to 64 characters, see RFC 5280. + format!("CN=update-security-config.{}", self.cluster.uid) + } + pub fn tls_config(&self) -> serde_json::Map { let mut config = serde_json::Map::new(); + let opensearch_path_conf = self.opensearch_path_conf(); - // TLS config for TRANSPORT port which is always enabled. - config.insert( - CONFIG_OPTION_PLUGINS_SECURITY_SSL_TRANSPORT_ENABLED.to_owned(), - json!(true), - ); - config.insert( - CONFIG_OPTION_PLUGINS_SECURITY_SSL_TRANSPORT_PEMCERT_FILEPATH.to_owned(), - json!(format!("{opensearch_path_conf}/tls/internal/tls.crt")), - ); - config.insert( - CONFIG_OPTION_PLUGINS_SECURITY_SSL_TRANSPORT_PEMKEY_FILEPATH.to_owned(), - json!(format!("{opensearch_path_conf}/tls/internal/tls.key")), - ); - config.insert( - CONFIG_OPTION_PLUGINS_SECURITY_SSL_TRANSPORT_PEMTRUSTEDCAS_FILEPATH.to_owned(), - json!(format!("{opensearch_path_conf}/tls/internal/ca.crt")), - ); + if self + .role_group_security_mode + .tls_internal_secret_class() + .is_some() + { + config.insert( + CONFIG_OPTION_PLUGINS_SECURITY_SSL_TRANSPORT_ENABLED.to_owned(), + json!(true), + ); + config.insert( + CONFIG_OPTION_PLUGINS_SECURITY_SSL_TRANSPORT_PEMCERT_FILEPATH.to_owned(), + json!(format!("{opensearch_path_conf}/tls/internal/tls.crt")), + ); + config.insert( + CONFIG_OPTION_PLUGINS_SECURITY_SSL_TRANSPORT_PEMKEY_FILEPATH.to_owned(), + json!(format!("{opensearch_path_conf}/tls/internal/tls.key")), + ); + config.insert( + CONFIG_OPTION_PLUGINS_SECURITY_SSL_TRANSPORT_PEMTRUSTEDCAS_FILEPATH.to_owned(), + json!(format!("{opensearch_path_conf}/tls/internal/ca.crt")), + ); + } - // TLS config for HTTP port (REST API) (optional). - if self.cluster.tls_config.server_secret_class.is_some() { + if self + .role_group_security_mode + .tls_server_secret_class() + .is_some() + { config.insert( CONFIG_OPTION_PLUGINS_SECURITY_SSL_HTTP_ENABLED.to_owned(), json!(true), @@ -270,25 +331,6 @@ impl NodeConfig { config } - /// Returns `true` if TLS is enabled on the HTTP port - pub fn tls_on_http_port_enabled(&self) -> bool { - self.opensearch_config() - .get(CONFIG_OPTION_PLUGINS_SECURITY_SSL_HTTP_ENABLED) - .and_then(Self::value_as_bool) - == Some(true) - } - - /// Converts the given JSON value to [`bool`] if possible - pub fn value_as_bool(value: &Value) -> Option { - value.as_bool().or( - // OpenSearch parses the strings "true" and "false" as boolean, see - // https://github.com/opensearch-project/OpenSearch/blob/3.4.0/libs/common/src/main/java/org/opensearch/common/Booleans.java#L45-L84 - value - .as_str() - .and_then(|value| FromStr::from_str(value).ok()), - ) - } - /// Creates environment variables for the OpenSearch configurations /// /// The environment variables should only contain node-specific configuration options. @@ -333,15 +375,16 @@ impl NodeConfig { ) .with_value( &EnvVarName::from_str_unsafe(CONFIG_OPTION_NODE_ROLES), - self.role_group_config - .config - .node_roles - .iter() - .map(|node_role| format!("{node_role}")) - .collect::>() - // Node roles cannot contain commas, therefore creating a comma-separated list - // is safe. - .join(","), + Self::to_comma_separated_list( + &self + .role_group_config + .config + .node_roles + .iter() + .map(|node_role| format!("{node_role}")) + .collect::>(), + ) + .expect("Node roles cannot contain commas, therefore creating a comma-separated list is safe."), ); if let Some(initial_cluster_manager_nodes) = self.initial_cluster_manager_nodes() { @@ -437,13 +480,13 @@ impl NodeConfig { .role_group_config .config .node_roles - .contains(&v1alpha1::NodeRole::ClusterManager) + .contains(&ValidatedNodeRole::ClusterManager) { None } else { let cluster_manager_configs = self .cluster - .role_group_configs_filtered_by_node_role(&v1alpha1::NodeRole::ClusterManager); + .role_group_configs_filtered_by_node_role(&ValidatedNodeRole::ClusterManager); // This setting requires node names as set in NODE_NAME. // The node names are set to the pod names with @@ -462,8 +505,8 @@ impl NodeConfig { .map(|i| format!("{}-{i}", role_group_resource_names.stateful_set_name())), ); } - // Pod names cannot contain commas, therefore creating a comma-separated list is safe. - Some(pod_names.join(",")) + + Some(Self::to_comma_separated_list(&pod_names).expect("Pod names cannot contain commas, therefore creating a comma-separated list is safe.")) } } @@ -483,11 +526,21 @@ impl NodeConfig { .and_then(|env_var| env_var.value.clone()) .unwrap_or(format!("{opensearch_home}/config")) } + + fn to_comma_separated_list(values: &[String]) -> Option { + if values.iter().any(|value| value.contains(",")) { + None + } else if values.is_empty() { + Some("[]".to_owned()) + } else { + Some(values.join(",")) + } + } } #[cfg(test)] mod tests { - use std::collections::BTreeMap; + use std::{collections::BTreeMap, str::FromStr}; use pretty_assertions::assert_eq; use stackable_operator::{ @@ -505,13 +558,15 @@ mod tests { use super::*; use crate::{ - controller::{ValidatedLogging, ValidatedOpenSearchConfig}, - crd::{NodeRoles, v1alpha1}, + controller::{ValidatedLogging, ValidatedOpenSearchConfig, ValidatedSecurity}, + crd::v1alpha1, framework::{ product_logging::framework::ValidatedContainerLogConfigChoice, role_utils::GenericProductSpecificCommonConfig, types::{ - kubernetes::{ListenerClassName, NamespaceName}, + kubernetes::{ + ConfigMapKey, ConfigMapName, ListenerClassName, NamespaceName, SecretClassName, + }, operator::{ClusterName, ProductVersion, RoleGroupName}, }, }, @@ -551,12 +606,13 @@ mod tests { ), vector_container: None, }, - node_roles: NodeRoles(vec![ - v1alpha1::NodeRole::ClusterManager, - v1alpha1::NodeRole::Data, - v1alpha1::NodeRole::Ingest, - v1alpha1::NodeRole::RemoteClusterClient, - ]), + node_roles: [ + ValidatedNodeRole::ClusterManager, + ValidatedNodeRole::Data, + ValidatedNodeRole::Ingest, + ValidatedNodeRole::RemoteClusterClient, + ] + .into(), requested_secret_lifetime: Duration::from_str("1d") .expect("should be a valid duration"), resources: Resources::default(), @@ -582,6 +638,30 @@ mod tests { product_specific_common_config: GenericProductSpecificCommonConfig::default(), }; + let security_settings = v1alpha1::SecuritySettings { + config: v1alpha1::SecuritySettingsFileType { + managed_by: v1alpha1::SecuritySettingsFileTypeManagedBy::Operator, + content: v1alpha1::SecuritySettingsFileTypeContent::ValueFrom( + v1alpha1::SecuritySettingsFileTypeContentValueFrom::ConfigMapKeyRef( + v1alpha1::ConfigMapKeyRef { + name: ConfigMapName::from_str_unsafe("security-config"), + key: ConfigMapKey::from_str_unsafe("config.yml"), + }, + ), + ), + }, + ..v1alpha1::SecuritySettings::default() + }; + let tls_server_secret_class = SecretClassName::from_str_unsafe("tls"); + let tls_internal_secret_class = SecretClassName::from_str_unsafe("tls"); + + let validated_security = ValidatedSecurity::ManagedByOperator { + managing_role_group: role_group_name.clone(), + settings: security_settings.clone(), + tls_server_secret_class: tls_server_secret_class.clone(), + tls_internal_secret_class: tls_internal_secret_class.clone(), + }; + let cluster = ValidatedCluster::new( ResolvedProductImage { product_version: "3.4.0".to_owned(), @@ -601,15 +681,22 @@ mod tests { role_group_config.clone(), )] .into(), - v1alpha1::OpenSearchTls::default(), + validated_security, vec![], None, ); + let role_group_security_config = RoleGroupSecurityMode::Managing { + settings: security_settings.clone(), + tls_server_secret_class: tls_server_secret_class.clone(), + tls_internal_secret_class: tls_internal_secret_class.clone(), + }; + NodeConfig::new( cluster, role_group_name, role_group_config, + role_group_security_config, ServiceName::from_str_unsafe("my-opensearch-seed-nodes"), DomainName::from_str("cluster.local").expect("should be a valid domain name"), ServiceName::from_str_unsafe("my-opensearch-cluster-default-headless"), @@ -630,6 +717,7 @@ mod tests { "network.host: \"0.0.0.0\"\n", "node.attr.role-group: \"data\"\n", "path.logs: \"/stackable/log/opensearch\"\n", + "plugins.security.authcz.admin_dn: \"CN=update-security-config.0b1e30e6-326e-4c1a-868d-ad6598b49e8b\"\n", "plugins.security.nodes_dn: [\"CN=generated certificate for pod\"]\n", "plugins.security.ssl.http.enabled: true\n", "plugins.security.ssl.http.pemcert_filepath: \"/stackable/opensearch/config/tls/server/tls.crt\"\n", @@ -647,59 +735,20 @@ mod tests { } #[test] - pub fn test_tls_on_http_port_enabled() { - let node_config_tls_undefined = node_config(TestConfig::default()); - - let node_config_tls_enabled = node_config(TestConfig { - config_settings: &[("plugins.security.ssl.http.enabled", "true")], - ..TestConfig::default() - }); - - let node_config_tls_disabled = node_config(TestConfig { - config_settings: &[("plugins.security.ssl.http.enabled", "false")], - ..TestConfig::default() - }); - - assert!(node_config_tls_undefined.tls_on_http_port_enabled()); - assert!(node_config_tls_enabled.tls_on_http_port_enabled()); - assert!(!node_config_tls_disabled.tls_on_http_port_enabled()); - } - - #[test] - pub fn test_value_as_bool() { - // boolean - assert_eq!(Some(true), NodeConfig::value_as_bool(&Value::Bool(true))); - assert_eq!(Some(false), NodeConfig::value_as_bool(&Value::Bool(false))); + pub fn test_super_admin_dn() { + let node_config = node_config(TestConfig::default()); - // valid strings - assert_eq!( - Some(true), - NodeConfig::value_as_bool(&Value::String("true".to_owned())) - ); - assert_eq!( - Some(false), - NodeConfig::value_as_bool(&Value::String("false".to_owned())) - ); - - // invalid strings - assert_eq!( - None, - NodeConfig::value_as_bool(&Value::String("True".to_owned())) - ); + let super_admin_dn = node_config.super_admin_dn(); + let parts: Vec<&str> = super_admin_dn.split("=").collect(); - // invalid types - assert_eq!(None, NodeConfig::value_as_bool(&Value::Null)); - assert_eq!( - None, - NodeConfig::value_as_bool(&Value::Number( - serde_json::Number::from_i128(1).expect("should be a valid number") - )) - ); - assert_eq!(None, NodeConfig::value_as_bool(&Value::Array(vec![]))); assert_eq!( - None, - NodeConfig::value_as_bool(&Value::Object(serde_json::Map::new())) + vec![ + "CN", + "update-security-config.0b1e30e6-326e-4c1a-868d-ad6598b49e8b" + ], + parts ); + assert!(parts[1].len() <= 64); } #[test] @@ -792,4 +841,30 @@ mod tests { node_config_multiple_nodes.initial_cluster_manager_nodes() ); } + + #[test] + pub fn test_to_comma_separated_list() { + assert_eq!( + None, + NodeConfig::to_comma_separated_list(&[ + "one".to_owned(), + "two,three".to_owned(), + "four".to_owned() + ]) + ); + + assert_eq!( + Some("[]".to_owned()), + NodeConfig::to_comma_separated_list(&[]) + ); + + assert_eq!( + Some("one,two,three".to_owned()), + NodeConfig::to_comma_separated_list(&[ + "one".to_owned(), + "two".to_owned(), + "three".to_owned() + ]) + ); + } } diff --git a/rust/operator-binary/src/controller/build/product_logging/vector-test.yaml b/rust/operator-binary/src/controller/build/product_logging/vector-test.yaml index 0468f60..9325f90 100644 --- a/rust/operator-binary/src/controller/build/product_logging/vector-test.yaml +++ b/rust/operator-binary/src/controller/build/product_logging/vector-test.yaml @@ -297,6 +297,37 @@ tests: "timestamp": t'2025-10-01T10:47:28.582Z' } + assert_eq!(expected_log_event, .) + - name: Test opensearch_server log entry with DEPRECATION level + inputs: + - type: log + insert_at: processed_files_opensearch_server + log_fields: + file: /stackable/log/opensearch/opensearch_server.json + message: | + {"type": "server", "timestamp": "2025-10-01T12:47:28,582Z", "level": "DEPRECATION", "component": "o.o.d.t.TransportInfo", "cluster.name": "opensearch", "node.name": "opensearch-nodes-cluster-manager-0", "message": "transport.publish_address was printed as [ip:port] instead of [hostname/ip:port]. This format is deprecated and will change to [hostname/ip:port] in a future version. Use -Dopensearch.transport.cname_in_publish_address=true to enforce non-deprecated formatting.", "cluster.uuid": "Jh1D6YAhTmyzkHI7vM1WWw", "node.id": "sk-r0P_TTYuPqaamTFbjKg" } + pod: opensearch-nodes-cluster-manager-0 + source_type: file + timestamp: 2025-10-01T12:47:29.473487331Z + outputs: + - extract_from: extended_logs + conditions: + - type: vrl + source: | + expected_log_event = { + "cluster": "opensearch", + "container": "opensearch", + "file": "opensearch_server.json", + "level": "WARN", + "logger": "o.o.d.t.TransportInfo", + "message": "transport.publish_address was printed as [ip:port] instead of [hostname/ip:port]. This format is deprecated and will change to [hostname/ip:port] in a future version. Use -Dopensearch.transport.cname_in_publish_address=true to enforce non-deprecated formatting.", + "namespace": "default", + "pod": "opensearch-nodes-cluster-manager-0", + "role": "nodes", + "roleGroup": "cluster-manager", + "timestamp": t'2025-10-01T10:47:28.582Z' + } + assert_eq!(expected_log_event, .) - name: Test opensearch_server log entry with unknown level inputs: diff --git a/rust/operator-binary/src/controller/build/product_logging/vector.yaml b/rust/operator-binary/src/controller/build/product_logging/vector.yaml index c01ac7c..205ed08 100644 --- a/rust/operator-binary/src/controller/build/product_logging/vector.yaml +++ b/rust/operator-binary/src/controller/build/product_logging/vector.yaml @@ -68,10 +68,12 @@ transforms: level, err = string(event.level) if err != null { .errors = push(.errors, "Level not found, using \"" + .level + "\" instead.") - } else if !includes(["TRACE", "DEBUG", "INFO", "WARN", "ERROR", "FATAL"], level) { - .errors = push(.errors, "Level \"" + level + "\" unknown, using \"" + .level + "\" instead.") - } else { + } else if level == "DEPRECATION" { + .level = "WARN" + } else if includes(["TRACE", "DEBUG", "INFO", "WARN", "ERROR", "FATAL"], level) { .level = level + } else { + .errors = push(.errors, "Level \"" + level + "\" unknown, using \"" + .level + "\" instead.") } .message, err = string(event.message) diff --git a/rust/operator-binary/src/controller/build/role_builder.rs b/rust/operator-binary/src/controller/build/role_builder.rs index 2a174e8..385e0fc 100644 --- a/rust/operator-binary/src/controller/build/role_builder.rs +++ b/rust/operator-binary/src/controller/build/role_builder.rs @@ -67,7 +67,7 @@ impl<'a> RoleBuilder<'a> { .map(|(role_group_name, role_group_config)| { RoleGroupBuilder::new( self.resource_names.service_account_name(), - self.cluster.clone(), + &self.cluster, role_group_name.clone(), role_group_config.clone(), self.context_names, @@ -171,7 +171,7 @@ impl<'a> RoleBuilder<'a> { let metadata = self.common_metadata(discovery_config_map_name(&self.cluster.name)); - let protocol = if self.cluster.tls_config.server_secret_class.is_some() { + let protocol = if self.cluster.is_server_tls_enabled() { "https" } else { "http" @@ -336,12 +336,12 @@ mod tests { controller::{ ContextNames, OpenSearchRoleGroupConfig, ValidatedCluster, ValidatedContainerLogConfigChoice, ValidatedDiscoveryEndpoint, ValidatedLogging, - ValidatedOpenSearchConfig, + ValidatedNodeRole, ValidatedOpenSearchConfig, ValidatedSecurity, build::role_builder::{ discovery_config_map_name, discovery_service_listener_name, seed_nodes_service_name, }, }, - crd::{NodeRoles, v1alpha1}, + crd::v1alpha1, framework::{ builder::pod::container::EnvVarSet, role_utils::GenericProductSpecificCommonConfig, @@ -349,7 +349,7 @@ mod tests { common::Port, kubernetes::{ ConfigMapName, Hostname, ListenerClassName, ListenerName, NamespaceName, - ServiceName, + SecretClassName, ServiceName, }, operator::{ ClusterName, ControllerName, OperatorName, ProductName, ProductVersion, @@ -385,12 +385,13 @@ mod tests { ), vector_container: None, }, - node_roles: NodeRoles(vec![ - v1alpha1::NodeRole::ClusterManager, - v1alpha1::NodeRole::Data, - v1alpha1::NodeRole::Ingest, - v1alpha1::NodeRole::RemoteClusterClient, - ]), + node_roles: [ + ValidatedNodeRole::ClusterManager, + ValidatedNodeRole::Data, + ValidatedNodeRole::Ingest, + ValidatedNodeRole::RemoteClusterClient, + ] + .into(), requested_secret_lifetime: Duration::from_str("1d") .expect("should be a valid duration"), resources: Resources::default(), @@ -427,7 +428,11 @@ mod tests { role_group_config.clone(), )] .into(), - v1alpha1::OpenSearchTls::default(), + ValidatedSecurity::ManagedByApi { + settings: v1alpha1::SecuritySettings::default(), + tls_server_secret_class: Some(SecretClassName::from_str_unsafe("tls")), + tls_internal_secret_class: SecretClassName::from_str_unsafe("tls"), + }, vec![], Some(ValidatedDiscoveryEndpoint { hostname: Hostname::from_str_unsafe("1.2.3.4"), diff --git a/rust/operator-binary/src/controller/build/role_group_builder.rs b/rust/operator-binary/src/controller/build/role_group_builder.rs index 0a1c1bf..4eb332e 100644 --- a/rust/operator-binary/src/controller/build/role_group_builder.rs +++ b/rust/operator-binary/src/controller/build/role_group_builder.rs @@ -1,12 +1,16 @@ -//! Builder for role-group resources +//! Builder for role group resources use std::{collections::BTreeMap, str::FromStr}; use stackable_operator::{ builder::{ meta::ObjectMetaBuilder, - pod::volume::{SecretFormat, SecretOperatorVolumeSourceBuilder, VolumeBuilder}, + pod::{ + container::FieldPathEnvVar, + volume::{SecretFormat, SecretOperatorVolumeSourceBuilder, VolumeBuilder}, + }, }, + commons::resources::{CpuLimits, MemoryLimits, Resources}, constants::RESTART_CONTROLLER_ENABLED_LABEL, crd::listener::{self}, k8s_openapi::{ @@ -29,7 +33,6 @@ use stackable_operator::{ VECTOR_CONFIG_FILE, calculate_log_volume_size_limit, create_vector_shutdown_file_command, remove_vector_shutdown_file_command, }, - shared::time::Duration, utils::COMMON_BASH_TRAP_FUNCTIONS, }; @@ -43,17 +46,17 @@ use crate::{ constant, controller::{ ContextNames, HTTP_PORT, HTTP_PORT_NAME, OpenSearchRoleGroupConfig, TRANSPORT_PORT, - TRANSPORT_PORT_NAME, ValidatedCluster, + TRANSPORT_PORT_NAME, ValidatedCluster, ValidatedNodeRole, ValidatedSecurity, build::product_logging::config::{ MAX_OPENSEARCH_SERVER_LOG_FILES_SIZE, vector_config_file_extra_env_vars, }, }, - crd::v1alpha1, + crd::{ExtendedSecuritySettingsFileType, v1alpha1}, framework::{ builder::{ meta::ownerreference_from_resource, pod::{ - container::new_container_builder, + container::{EnvVarName, EnvVarSet, new_container_builder}, volume::{ListenerReference, listener_operator_volume_source_builder_build_pvc}, }, }, @@ -86,7 +89,11 @@ constant!(DISCOVERY_SERVICE_LISTENER_VOLUME_NAME: PersistentVolumeClaimName = "d const DISCOVERY_SERVICE_LISTENER_VOLUME_DIR: &str = "/stackable/listeners/discovery-service"; constant!(TLS_SERVER_VOLUME_NAME: VolumeName = "tls-server"); +constant!(TLS_SERVER_CA_VOLUME_NAME: VolumeName = "tls-server-ca"); +const TLS_SERVER_CA_VOLUME_SIZE: &str = "1Mi"; constant!(TLS_INTERNAL_VOLUME_NAME: VolumeName = "tls-internal"); +constant!(TLS_ADMIN_CERT_VOLUME_NAME: VolumeName = "tls-admin-cert"); +const TLS_ADMIN_CERT_VOLUME_SIZE: &str = "1Mi"; constant!(LOG_VOLUME_NAME: VolumeName = "log"); const LOG_VOLUME_DIR: &str = "/stackable/log"; @@ -97,22 +104,100 @@ const OPENSEARCH_KEYSTORE_SECRETS_DIRECTORY: &str = "keystore-secrets"; constant!(OPENSEARCH_KEYSTORE_VOLUME_NAME: VolumeName = "keystore"); const OPENSEARCH_KEYSTORE_VOLUME_SIZE: &str = "1Mi"; -/// Builder for role-group resources +/// Depending on the security settings, the role group builder operates in one of these modes. +#[derive(Clone, Debug)] +pub enum RoleGroupSecurityMode { + /// The security plugin is enabled and all settings are initialized by an arbitrary role group. + /// The security settings are mounted to the main container for all role groups. + Initializing { + settings: v1alpha1::SecuritySettings, + tls_server_secret_class: Option, + tls_internal_secret_class: SecretClassName, + }, + + /// The security plugin is enabled and some or all settings are initialized and updated by this + /// role group. + /// The admin certificate is created in the [`v1alpha1::Container::CreateAdminCertificate`] + /// init container and the security settings are mounted to and updated in the + /// [`v1alpha1::Container::UpdateSecurityConfig`] side-car container. + Managing { + settings: v1alpha1::SecuritySettings, + tls_server_secret_class: SecretClassName, + tls_internal_secret_class: SecretClassName, + }, + + /// The security plugin is enabled and the settings are managed by another role group. + /// The security settings are not mounted. + Participating { + tls_server_secret_class: SecretClassName, + tls_internal_secret_class: SecretClassName, + }, + + /// The security plugin is disabled. + Disabled, +} + +impl RoleGroupSecurityMode { + /// Return the TLS server SecretClass if set + pub fn tls_server_secret_class(&self) -> Option { + if let RoleGroupSecurityMode::Initializing { + tls_server_secret_class: Some(tls_server_secret_class), + .. + } + | RoleGroupSecurityMode::Managing { + tls_server_secret_class, + .. + } + | RoleGroupSecurityMode::Participating { + tls_server_secret_class, + .. + } = self + { + Some(tls_server_secret_class.clone()) + } else { + None + } + } + + /// Return the TLS internal SecretClass if set + pub fn tls_internal_secret_class(&self) -> Option { + if let RoleGroupSecurityMode::Initializing { + tls_internal_secret_class, + .. + } + | RoleGroupSecurityMode::Managing { + tls_internal_secret_class, + .. + } + | RoleGroupSecurityMode::Participating { + tls_internal_secret_class, + .. + } = self + { + Some(tls_internal_secret_class.clone()) + } else { + None + } + } +} + +/// Builder for role group resources pub struct RoleGroupBuilder<'a> { service_account_name: ServiceAccountName, - cluster: ValidatedCluster, + cluster: &'a ValidatedCluster, node_config: NodeConfig, role_group_name: RoleGroupName, role_group_config: OpenSearchRoleGroupConfig, context_names: &'a ContextNames, resource_names: ResourceNames, discovery_service_listener_name: ListenerName, + security_mode: RoleGroupSecurityMode, } impl<'a> RoleGroupBuilder<'a> { pub fn new( service_account_name: ServiceAccountName, - cluster: ValidatedCluster, + cluster: &'a ValidatedCluster, role_group_name: RoleGroupName, role_group_config: OpenSearchRoleGroupConfig, context_names: &'a ContextNames, @@ -124,13 +209,46 @@ impl<'a> RoleGroupBuilder<'a> { role_name: ValidatedCluster::role_name(), role_group_name: role_group_name.clone(), }; + + let security_mode = match cluster.security.clone() { + ValidatedSecurity::ManagedByApi { + settings, + tls_server_secret_class, + tls_internal_secret_class, + } => RoleGroupSecurityMode::Initializing { + settings, + tls_server_secret_class, + tls_internal_secret_class, + }, + ValidatedSecurity::ManagedByOperator { + managing_role_group, + settings, + tls_server_secret_class, + tls_internal_secret_class, + } if managing_role_group == role_group_name => RoleGroupSecurityMode::Managing { + settings, + tls_server_secret_class, + tls_internal_secret_class, + }, + ValidatedSecurity::ManagedByOperator { + tls_server_secret_class, + tls_internal_secret_class, + .. + } => RoleGroupSecurityMode::Participating { + tls_server_secret_class, + tls_internal_secret_class, + }, + ValidatedSecurity::Disabled => RoleGroupSecurityMode::Disabled, + }; + RoleGroupBuilder { service_account_name, - cluster: cluster.clone(), + cluster, node_config: NodeConfig::new( cluster.clone(), role_group_name.clone(), role_group_config.clone(), + security_mode.clone(), seed_nodes_service_name, context_names.cluster_domain_name.clone(), resource_names.headless_service_name(), @@ -140,11 +258,12 @@ impl<'a> RoleGroupBuilder<'a> { context_names, resource_names, discovery_service_listener_name, + security_mode, } } - /// Builds the [`ConfigMap`] containing the configuration files of the role-group - /// [`StatefulSet`] + /// Builds the [`ConfigMap`] containing the configuration files for the [`StatefulSet`] of the + /// role group pub fn build_config_map(&self) -> ConfigMap { let metadata = self .common_metadata(self.resource_names.role_group_config_map()) @@ -175,6 +294,19 @@ impl<'a> RoleGroupBuilder<'a> { data.insert(VECTOR_CONFIG_FILE.to_owned(), vector_config_file_content()); } + if let RoleGroupSecurityMode::Initializing { settings, .. } + | RoleGroupSecurityMode::Managing { settings, .. } = &self.security_mode + { + for file_type in settings { + if let v1alpha1::SecuritySettingsFileTypeContent::Value( + v1alpha1::SecuritySettingsFileTypeContentValue { value }, + ) = &file_type.content + { + data.insert(file_type.filename.to_owned(), value.to_string()); + } + } + } + ConfigMap { metadata, data: Some(data), @@ -182,7 +314,7 @@ impl<'a> RoleGroupBuilder<'a> { } } - /// Builds the role-group [`StatefulSet`] + /// Builds the [`StatefulSet`] of the role group pub fn build_stateful_set(&self) -> StatefulSet { let metadata = self .common_metadata(self.resource_names.stateful_set_name()) @@ -251,7 +383,7 @@ impl<'a> RoleGroupBuilder<'a> { } } - /// Builds the [`PodTemplateSpec`] for the role-group [`StatefulSet`] + /// Builds the [`PodTemplateSpec`] for the [`StatefulSet`] of the role group fn build_pod_template(&self) -> PodTemplateSpec { let mut node_role_labels = Labels::new(); @@ -271,134 +403,57 @@ impl<'a> RoleGroupBuilder<'a> { ) .build(); - let opensearch_container = self.build_opensearch_container(); - let vector_container = self - .role_group_config - .config - .logging - .vector_container - .as_ref() - .map(|vector_container_log_config| { - vector_container( - &v1alpha1::Container::Vector.to_container_name(), - &self.cluster.image, - vector_container_log_config, - &self.resource_names, - &CONFIG_VOLUME_NAME, - &LOG_VOLUME_NAME, - vector_config_file_extra_env_vars(), - ) - }); + let containers = [ + Some(self.build_opensearch_container()), + self.build_maybe_vector_container(), + self.build_maybe_security_config_container(), + ] + .into_iter() + .flatten() + .collect(); - let mut init_containers = vec![]; - if let Some(keystore_init_container) = self.build_maybe_keystore_init_container() { - init_containers.push(keystore_init_container); - } + let init_containers = [ + self.build_maybe_keystore_init_container(), + self.build_maybe_admin_certificate_init_container(), + ] + .into_iter() + .flatten() + .collect(); - let log_config_volume_config_map = - if let ValidatedContainerLogConfigChoice::Custom(config_map_name) = - &self.role_group_config.config.logging.opensearch_container - { - config_map_name.clone() - } else { - self.resource_names.role_group_config_map() - }; + let volumes = [ + self.build_config_volumes(), + self.build_log_volumes(), + self.build_security_volumes(), + self.build_keystore_volumes(), + ] + .into_iter() + .flatten() + .collect(); - let mut internal_tls_volume_service_scopes = vec![]; - if self + let affinity = Affinity { + node_affinity: self.role_group_config.config.affinity.node_affinity.clone(), + pod_affinity: self.role_group_config.config.affinity.pod_affinity.clone(), + pod_anti_affinity: self + .role_group_config + .config + .affinity + .pod_anti_affinity + .clone(), + }; + + let node_selector = self .role_group_config .config - .node_roles - .contains(&v1alpha1::NodeRole::ClusterManager) - { - internal_tls_volume_service_scopes - .push(self.node_config.seed_nodes_service_name.clone()); - } - let internal_tls_volume = self.build_tls_volume( - &TLS_INTERNAL_VOLUME_NAME, - &self.cluster.tls_config.internal_secret_class, - internal_tls_volume_service_scopes, - SecretFormat::TlsPem, - &self.role_group_config.config.requested_secret_lifetime, - vec![ROLE_GROUP_LISTENER_VOLUME_NAME.clone()], - ); - - let mut volumes = vec![ - Volume { - name: CONFIG_VOLUME_NAME.to_string(), - config_map: Some(ConfigMapVolumeSource { - default_mode: Some(0o660), - name: self.resource_names.role_group_config_map().to_string(), - ..Default::default() - }), - ..Volume::default() - }, - Volume { - name: LOG_CONFIG_VOLUME_NAME.to_string(), - config_map: Some(ConfigMapVolumeSource { - default_mode: Some(0o660), - name: log_config_volume_config_map.to_string(), - ..Default::default() - }), - ..Volume::default() - }, - Volume { - name: LOG_VOLUME_NAME.to_string(), - empty_dir: Some(EmptyDirVolumeSource { - size_limit: Some(calculate_log_volume_size_limit(&[ - MAX_OPENSEARCH_SERVER_LOG_FILES_SIZE, - ])), - ..EmptyDirVolumeSource::default() - }), - ..Volume::default() - }, - internal_tls_volume, - ]; - - if let Some(tls_http_secret_class_name) = &self.cluster.tls_config.server_secret_class { - let mut listener_scopes = vec![ROLE_GROUP_LISTENER_VOLUME_NAME.to_owned()]; - if self.role_group_config.config.discovery_service_exposed { - listener_scopes.push(DISCOVERY_SERVICE_LISTENER_VOLUME_NAME.to_owned()); - } - - volumes.push(self.build_tls_volume( - &TLS_SERVER_VOLUME_NAME, - tls_http_secret_class_name, - vec![], - SecretFormat::TlsPem, - &self.role_group_config.config.requested_secret_lifetime, - listener_scopes, - )) + .affinity + .node_selector + .clone() + .map(|wrapped| wrapped.node_selector); + + let security_context = PodSecurityContext { + fs_group: Some(1000), + ..PodSecurityContext::default() }; - if !self.cluster.keystores.is_empty() { - volumes.push(Volume { - name: OPENSEARCH_KEYSTORE_VOLUME_NAME.to_string(), - empty_dir: Some(EmptyDirVolumeSource { - size_limit: Some(Quantity(OPENSEARCH_KEYSTORE_VOLUME_SIZE.to_owned())), - ..EmptyDirVolumeSource::default() - }), - ..Volume::default() - }) - } - - for (index, keystore) in self.cluster.keystores.iter().enumerate() { - volumes.push(Volume { - name: format!("keystore-{index}"), - secret: Some(SecretVolumeSource { - default_mode: Some(0o660), - secret_name: Some(keystore.secret_key_ref.name.to_string()), - items: Some(vec![KeyToPath { - key: keystore.secret_key_ref.key.to_string(), - path: keystore.secret_key_ref.key.to_string(), - ..KeyToPath::default() - }]), - ..SecretVolumeSource::default() - }), - ..Volume::default() - }); - } - // The PodBuilder is not used because it re-validates the values which are already // validated. For instance, it would be necessary to convert the // termination_grace_period_seconds into a Duration, the PodBuilder parses the Duration, @@ -406,32 +461,11 @@ impl<'a> RoleGroupBuilder<'a> { let mut pod_template = PodTemplateSpec { metadata: Some(metadata), spec: Some(PodSpec { - affinity: Some(Affinity { - node_affinity: self.role_group_config.config.affinity.node_affinity.clone(), - pod_affinity: self.role_group_config.config.affinity.pod_affinity.clone(), - pod_anti_affinity: self - .role_group_config - .config - .affinity - .pod_anti_affinity - .clone(), - }), - containers: [Some(opensearch_container), vector_container] - .into_iter() - .flatten() - .collect(), + affinity: Some(affinity), + containers, init_containers: Some(init_containers), - node_selector: self - .role_group_config - .config - .affinity - .node_selector - .clone() - .map(|wrapped| wrapped.node_selector), - security_context: Some(PodSecurityContext { - fs_group: Some(1000), - ..PodSecurityContext::default() - }), + node_selector, + security_context: Some(security_context), service_account_name: Some(self.service_account_name.to_string()), termination_grace_period_seconds: Some( self.role_group_config @@ -460,14 +494,14 @@ impl<'a> RoleGroupBuilder<'a> { ); labels.insert(Self::build_node_role_label( - &v1alpha1::NodeRole::ClusterManager, + &ValidatedNodeRole::ClusterManager, )); labels } /// Builds a label indicating the role of the OpenSearch node - fn build_node_role_label(node_role: &v1alpha1::NodeRole) -> Label { + fn build_node_role_label(node_role: &ValidatedNodeRole) -> Label { // It is not possible to check the infallibility of the following statement at // compile-time. Instead, it is tested in `tests::test_build_node_role_label`. Label::try_from(( @@ -477,11 +511,13 @@ impl<'a> RoleGroupBuilder<'a> { .expect("should be a valid label") } - /// Builds the container for the [`PodTemplateSpec`] + /// Builds the [`v1alpha1::Container::InitKeystore`] init container for the [`PodTemplateSpec`] + /// if keystores are defined fn build_maybe_keystore_init_container(&self) -> Option { if self.cluster.keystores.is_empty() { return None; } + let opensearch_home = self.node_config.opensearch_home(); let mut volume_mounts = vec![VolumeMount { mount_path: format!( @@ -504,32 +540,88 @@ impl<'a> RoleGroupBuilder<'a> { }); } - Some( + let container = new_container_builder(&v1alpha1::Container::InitKeystore.to_container_name()) .image_from_product_image(&self.cluster.image) - .command(vec![ - "/bin/bash".to_string(), - "-x".to_string(), - "-euo".to_string(), - "pipefail".to_string(), - "-c".to_string(), - ]) - .args(vec![format!( - "bin/opensearch-keystore create -for i in keystore-secrets/*; do - key=$(basename $i) - bin/opensearch-keystore add-file \"$key\" \"$i\" -done -cp --archive config/opensearch.keystore {OPENSEARCH_INITIALIZED_KEYSTORE_DIRECTORY_NAME}", - )]) + .command(vec!["/bin/bash".to_owned(), "-c".to_owned()]) + .args(vec![include_str!("scripts/init-keystore.sh").to_owned()]) .add_volume_mounts(volume_mounts) .expect("The mount paths are statically defined and there should be no duplicates.") .resources(self.role_group_config.config.resources.clone().into()) - .build(), - ) + .build(); + + Some(container) + } + + /// Builds the [`v1alpha1::Container::CreateAdminCertificate`] init container for the + /// [`PodTemplateSpec`] if the security mode is [`RoleGroupSecurityMode::Managing`] + fn build_maybe_admin_certificate_init_container(&self) -> Option { + let RoleGroupSecurityMode::Managing { .. } = self.security_mode else { + return None; + }; + + let env_vars = EnvVarSet::new() + .with_value( + &EnvVarName::from_str_unsafe("ADMIN_DN"), + self.node_config.super_admin_dn(), + ) + .with_field_path( + &EnvVarName::from_str_unsafe("POD_NAME"), + FieldPathEnvVar::Name, + ); + + let volume_mounts = vec![ + VolumeMount { + mount_path: "/stackable/tls-server/ca.crt".to_owned(), + name: TLS_SERVER_VOLUME_NAME.to_string(), + sub_path: Some("ca.crt".to_owned()), + read_only: Some(true), + ..VolumeMount::default() + }, + VolumeMount { + mount_path: "/stackable/tls-admin-cert".to_owned(), + name: TLS_ADMIN_CERT_VOLUME_NAME.to_string(), + read_only: Some(false), + ..VolumeMount::default() + }, + VolumeMount { + mount_path: "/stackable/tls-server-ca".to_owned(), + name: TLS_SERVER_CA_VOLUME_NAME.to_string(), + read_only: Some(false), + ..VolumeMount::default() + }, + ]; + + let container = + new_container_builder(&v1alpha1::Container::CreateAdminCertificate.to_container_name()) + .image_from_product_image(&self.cluster.image) + .command(vec!["/bin/bash".to_string(), "-c".to_string()]) + .args(vec![ + include_str!("scripts/create-admin-certificate.sh").to_owned(), + ]) + .add_env_vars(env_vars.into()) + .add_volume_mounts(volume_mounts) + .expect("The mount paths are statically defined and there should be no duplicates.") + .resources( + Resources::<()> { + memory: MemoryLimits { + limit: Some(Quantity("128Mi".to_owned())), + ..MemoryLimits::default() + }, + cpu: CpuLimits { + min: Some(Quantity("100m".to_owned())), + max: Some(Quantity("400m".to_owned())), + }, + ..Resources::default() + } + .into(), + ) + .build(); + + Some(container) } - /// Builds the container for the [`PodTemplateSpec`] + /// Builds the [`v1alpha1::Container::OpenSearch`] container for the [`PodTemplateSpec`] fn build_opensearch_container(&self) -> Container { // Probe values taken from the official Helm chart let startup_probe = Probe { @@ -589,11 +681,6 @@ cp --archive config/opensearch.keystore {OPENSEARCH_INITIALIZED_KEYSTORE_DIRECTO name: LOG_VOLUME_NAME.to_string(), ..VolumeMount::default() }, - VolumeMount { - mount_path: format!("{opensearch_path_conf}/tls/internal"), - name: TLS_INTERNAL_VOLUME_NAME.to_string(), - ..VolumeMount::default() - }, ]; if self.role_group_config.config.discovery_service_exposed { @@ -604,13 +691,42 @@ cp --archive config/opensearch.keystore {OPENSEARCH_INITIALIZED_KEYSTORE_DIRECTO }); } - if self.cluster.tls_config.server_secret_class.is_some() { + if self.security_mode.tls_internal_secret_class().is_some() { + volume_mounts.push(VolumeMount { + mount_path: format!("{opensearch_path_conf}/tls/internal"), + name: TLS_INTERNAL_VOLUME_NAME.to_string(), + ..VolumeMount::default() + }); + }; + + if self.security_mode.tls_server_secret_class().is_some() { volume_mounts.push(VolumeMount { - mount_path: format!("{opensearch_path_conf}/tls/server"), + mount_path: format!("{opensearch_path_conf}/tls/server/tls.crt"), name: TLS_SERVER_VOLUME_NAME.to_string(), + sub_path: Some("tls.crt".to_owned()), ..VolumeMount::default() }); - } + volume_mounts.push(VolumeMount { + mount_path: format!("{opensearch_path_conf}/tls/server/tls.key"), + name: TLS_SERVER_VOLUME_NAME.to_string(), + sub_path: Some("tls.key".to_owned()), + ..VolumeMount::default() + }); + volume_mounts.push(VolumeMount { + mount_path: format!("{opensearch_path_conf}/tls/server/ca.crt"), + name: if let RoleGroupSecurityMode::Managing { .. } = self.security_mode { + TLS_SERVER_CA_VOLUME_NAME.to_string() + } else { + TLS_SERVER_VOLUME_NAME.to_string() + }, + sub_path: Some("ca.crt".to_owned()), + ..VolumeMount::default() + }); + }; + + if let RoleGroupSecurityMode::Initializing { settings, .. } = &self.security_mode { + volume_mounts.extend(self.security_config_volume_mounts(settings)); + }; if !self.cluster.keystores.is_empty() { volume_mounts.push(VolumeMount { @@ -670,80 +786,514 @@ cp --archive config/opensearch.keystore {OPENSEARCH_INITIALIZED_KEYSTORE_DIRECTO .build() } - /// Builds the headless [`Service`] for the role-group - pub fn build_headless_service(&self) -> Service { - let metadata = self - .common_metadata(self.resource_names.headless_service_name()) - .with_labels(Self::prometheus_labels()) - .with_annotations(Self::prometheus_annotations( - self.node_config.tls_on_http_port_enabled(), - )) - .build(); - - let ports = vec![ - ServicePort { - name: Some(HTTP_PORT_NAME.to_owned()), - port: HTTP_PORT.into(), - ..ServicePort::default() - }, - ServicePort { - name: Some(TRANSPORT_PORT_NAME.to_owned()), - port: TRANSPORT_PORT.into(), - ..ServicePort::default() - }, - ]; + /// Builds the security settings volume mounts for the [`v1alpha1::Container::OpenSearch`] + /// container or the [`v1alpha1::Container::UpdateSecurityConfig`] container + fn security_config_volume_mounts( + &self, + settings: &v1alpha1::SecuritySettings, + ) -> Vec { + let mut volume_mounts = vec![]; - let service_spec = ServiceSpec { - // Internal communication does not need to be exposed - type_: Some("ClusterIP".to_string()), - cluster_ip: Some("None".to_string()), - ports: Some(ports), - selector: Some(self.pod_selector().into()), - publish_not_ready_addresses: Some(true), - ..ServiceSpec::default() - }; + let opensearch_path_conf = self.node_config.opensearch_path_conf(); - Service { - metadata, - spec: Some(service_spec), - status: None, + for file_type in settings { + volume_mounts.push(VolumeMount { + mount_path: format!( + "{opensearch_path_conf}/opensearch-security/{filename}", + filename = file_type.filename.to_owned() + ), + name: Self::security_settings_file_type_volume_name(&file_type).to_string(), + read_only: Some(true), + sub_path: Some(file_type.filename.to_owned()), + ..VolumeMount::default() + }); } + + volume_mounts } - /// Common labels for Prometheus - fn prometheus_labels() -> Labels { - Labels::try_from([("prometheus.io/scrape", "true")]).expect("should be a valid label") + fn security_settings_file_type_volume_name( + file_type: &ExtendedSecuritySettingsFileType, + ) -> VolumeName { + VolumeName::from_str(&format!("security-config-file-{}", file_type.id)) + .expect("should be a valid VolumeName") } - /// Common annotations for Prometheus - /// - /// These annotations can be used in a ServiceMonitor. - /// - /// see also - fn prometheus_annotations(tls_on_http_port_enabled: bool) -> Annotations { - Annotations::try_from([ - ( - "prometheus.io/path".to_owned(), - "/_prometheus/metrics".to_owned(), - ), - ("prometheus.io/port".to_owned(), HTTP_PORT.to_string()), - ( - "prometheus.io/scheme".to_owned(), - if tls_on_http_port_enabled { - "https".to_owned() - } else { - "http".to_owned() - }, - ), + /// Builds the [`v1alpha1::Container::Vector`] container for the [`PodTemplateSpec`] if it is + /// enabled + fn build_maybe_vector_container(&self) -> Option { + let vector_container_log_config = self + .role_group_config + .config + .logging + .vector_container + .as_ref()?; + + Some(vector_container( + &v1alpha1::Container::Vector.to_container_name(), + &self.cluster.image, + vector_container_log_config, + &self.resource_names, + &CONFIG_VOLUME_NAME, + &LOG_VOLUME_NAME, + vector_config_file_extra_env_vars(), + )) + } + + /// Builds the [`v1alpha1::Container::UpdateSecurityConfig`] container for the + /// [`PodTemplateSpec`] if the security mode is [`RoleGroupSecurityMode::Managing`] + fn build_maybe_security_config_container(&self) -> Option { + let RoleGroupSecurityMode::Managing { settings, .. } = &self.security_mode else { + return None; + }; + + let opensearch_path_conf = self.node_config.opensearch_path_conf(); + + let mut volume_mounts = vec![ + VolumeMount { + mount_path: format!("{opensearch_path_conf}/tls/tls.crt"), + name: TLS_ADMIN_CERT_VOLUME_NAME.to_string(), + read_only: Some(true), + sub_path: Some("tls.crt".to_owned()), + ..VolumeMount::default() + }, + VolumeMount { + mount_path: format!("{opensearch_path_conf}/tls/tls.key"), + name: TLS_ADMIN_CERT_VOLUME_NAME.to_string(), + read_only: Some(true), + sub_path: Some("tls.key".to_owned()), + ..VolumeMount::default() + }, + VolumeMount { + mount_path: format!("{opensearch_path_conf}/tls/ca.crt"), + name: TLS_SERVER_CA_VOLUME_NAME.to_string(), + read_only: Some(true), + sub_path: Some("ca.crt".to_owned()), + ..VolumeMount::default() + }, + VolumeMount { + mount_path: LOG_VOLUME_DIR.to_owned(), + name: LOG_VOLUME_NAME.to_string(), + ..VolumeMount::default() + }, + ]; + volume_mounts.extend(self.security_config_volume_mounts(settings)); + + let mut env_vars = EnvVarSet::new() + .with_value( + &EnvVarName::from_str_unsafe("OPENSEARCH_PATH_CONF"), + opensearch_path_conf, + ) + .with_field_path( + &EnvVarName::from_str_unsafe("POD_NAME"), + FieldPathEnvVar::Name, + ); + + for file_type in settings { + let managed_by_operator = + *file_type.managed_by == v1alpha1::SecuritySettingsFileTypeManagedBy::Operator; + + env_vars = env_vars.with_value( + &Self::security_settings_file_type_managed_by_env_var(&file_type), + managed_by_operator.to_string(), + ); + } + + let container = + new_container_builder(&v1alpha1::Container::UpdateSecurityConfig.to_container_name()) + .image_from_product_image(&self.cluster.image) + .command(vec!["/bin/bash".to_string(), "-c".to_string()]) + .args(vec![ + include_str!("scripts/update-security-config.sh").to_owned(), + ]) + .add_env_vars(env_vars.into()) + .add_volume_mounts(volume_mounts) + .expect("The mount paths are statically defined and there should be no duplicates.") + .resources( + Resources::<()> { + memory: MemoryLimits { + limit: Some(Quantity("512Mi".to_owned())), + ..MemoryLimits::default() + }, + cpu: CpuLimits { + min: Some(Quantity("100m".to_owned())), + max: Some(Quantity("400m".to_owned())), + }, + ..Resources::default() + } + .into(), + ) + .build(); + + Some(container) + } + + /// Environment variable which is used in the `update-security-config.sh` script to determine + /// if a security settings file type is managed by the operator + fn security_settings_file_type_managed_by_env_var( + file_type: &ExtendedSecuritySettingsFileType, + ) -> EnvVarName { + EnvVarName::from_str_unsafe(&format!("MANAGE_{}", file_type.id.to_uppercase())) + } + + /// Builds the config volumes for the [`PodTemplateSpec`] + fn build_config_volumes(&self) -> Vec { + vec![Volume { + name: CONFIG_VOLUME_NAME.to_string(), + config_map: Some(ConfigMapVolumeSource { + default_mode: Some(0o660), + name: self.resource_names.role_group_config_map().to_string(), + ..Default::default() + }), + ..Volume::default() + }] + } + + /// Builds the log volumes for the [`PodTemplateSpec`] + fn build_log_volumes(&self) -> Vec { + let log_config_volume_config_map = + if let ValidatedContainerLogConfigChoice::Custom(config_map_name) = + &self.role_group_config.config.logging.opensearch_container + { + config_map_name.clone() + } else { + self.resource_names.role_group_config_map() + }; + + vec![ + Volume { + name: LOG_CONFIG_VOLUME_NAME.to_string(), + config_map: Some(ConfigMapVolumeSource { + default_mode: Some(0o660), + name: log_config_volume_config_map.to_string(), + ..Default::default() + }), + ..Volume::default() + }, + Volume { + name: LOG_VOLUME_NAME.to_string(), + empty_dir: Some(EmptyDirVolumeSource { + size_limit: Some(calculate_log_volume_size_limit(&[ + MAX_OPENSEARCH_SERVER_LOG_FILES_SIZE, + ])), + ..EmptyDirVolumeSource::default() + }), + ..Volume::default() + }, + ] + } + + /// Builds the security volumes for the [`PodTemplateSpec`] depending on the + /// [`RoleGroupSecurityMode`] + fn build_security_volumes(&self) -> Vec { + let volumes = match &self.security_mode { + RoleGroupSecurityMode::Initializing { + settings, + tls_server_secret_class, + tls_internal_secret_class, + } => vec![ + Some(self.build_security_internal_tls_volumes(tls_internal_secret_class)), + tls_server_secret_class + .as_ref() + .map(|tls_server_secret_class| { + self.build_security_server_tls_volumes(tls_server_secret_class) + }), + Some(self.build_security_settings_volumes(settings)), + ] + .into_iter() + .flatten() + .collect(), + RoleGroupSecurityMode::Managing { + settings, + tls_server_secret_class, + tls_internal_secret_class, + } => vec![ + self.build_security_internal_tls_volumes(tls_internal_secret_class), + self.build_security_server_tls_volumes(tls_server_secret_class), + self.build_security_settings_volumes(settings), + self.build_security_server_tls_ca_volumes(), + self.build_security_admin_cert_volumes(), + ], + RoleGroupSecurityMode::Participating { + tls_server_secret_class, + tls_internal_secret_class, + } => vec![ + self.build_security_internal_tls_volumes(tls_internal_secret_class), + self.build_security_server_tls_volumes(tls_server_secret_class), + ], + RoleGroupSecurityMode::Disabled => vec![], + }; + + volumes.into_iter().flatten().collect() + } + + /// Builds the internal TLS volumes for the [`PodTemplateSpec`] + fn build_security_internal_tls_volumes( + &self, + tls_internal_secret_class: &SecretClassName, + ) -> Vec { + let mut volume_source_builder = + SecretOperatorVolumeSourceBuilder::new(tls_internal_secret_class); + + volume_source_builder + .with_pod_scope() + .with_listener_volume_scope(ROLE_GROUP_LISTENER_VOLUME_NAME.to_string()) + .with_format(SecretFormat::TlsPem) + .with_auto_tls_cert_lifetime(self.role_group_config.config.requested_secret_lifetime); + + if self + .role_group_config + .config + .node_roles + .contains(&ValidatedNodeRole::ClusterManager) + { + volume_source_builder.with_service_scope(&self.node_config.seed_nodes_service_name); + } + + let volume_source = volume_source_builder + .build() + .expect("volume should be built without parse errors"); + + vec![ + VolumeBuilder::new(TLS_INTERNAL_VOLUME_NAME.to_string()) + .ephemeral(volume_source) + .build(), + ] + } + + /// Builds the server TLS volumes for the [`PodTemplateSpec`] if a TLS server secret class is + /// defined + fn build_security_server_tls_volumes( + &self, + tls_server_secret_class: &SecretClassName, + ) -> Vec { + let mut volume_source_builder = + SecretOperatorVolumeSourceBuilder::new(tls_server_secret_class); + + volume_source_builder + .with_pod_scope() + .with_listener_volume_scope(ROLE_GROUP_LISTENER_VOLUME_NAME.to_string()) + .with_format(SecretFormat::TlsPem) + .with_auto_tls_cert_lifetime(self.role_group_config.config.requested_secret_lifetime); + + if self.role_group_config.config.discovery_service_exposed { + volume_source_builder + .with_listener_volume_scope(DISCOVERY_SERVICE_LISTENER_VOLUME_NAME.to_string()); + } + + let volume_source = volume_source_builder + .build() + .expect("volume should be built without parse errors"); + + vec![ + VolumeBuilder::new(TLS_SERVER_VOLUME_NAME.to_string()) + .ephemeral(volume_source) + .build(), + ] + } + + /// Builds the server TLS CA volumes for the [`PodTemplateSpec`] + /// It is not checked if these volumes are required in this role group. + fn build_security_server_tls_ca_volumes(&self) -> Vec { + vec![Volume { + name: TLS_SERVER_CA_VOLUME_NAME.to_string(), + empty_dir: Some(EmptyDirVolumeSource { + size_limit: Some(Quantity(TLS_SERVER_CA_VOLUME_SIZE.to_owned())), + ..EmptyDirVolumeSource::default() + }), + ..Volume::default() + }] + } + + /// Builds the security settings volumes for the [`PodTemplateSpec`] + /// It is not checked if these volumes are required in this role group. + fn build_security_settings_volumes( + &self, + settings: &v1alpha1::SecuritySettings, + ) -> Vec { + let mut volumes = vec![]; + + for file_type in settings { + let volume_name = Self::security_settings_file_type_volume_name(&file_type).to_string(); + + let volume = match &file_type.content { + v1alpha1::SecuritySettingsFileTypeContent::Value(_) => Volume { + name: volume_name, + config_map: Some(ConfigMapVolumeSource { + items: Some(vec![KeyToPath { + key: file_type.filename.to_owned(), + mode: Some(0o660), + path: file_type.filename.to_owned(), + }]), + name: self.resource_names.role_group_config_map().to_string(), + ..Default::default() + }), + ..Volume::default() + }, + v1alpha1::SecuritySettingsFileTypeContent::ValueFrom( + v1alpha1::SecuritySettingsFileTypeContentValueFrom::ConfigMapKeyRef( + v1alpha1::ConfigMapKeyRef { name, key }, + ), + ) => Volume { + name: volume_name, + config_map: Some(ConfigMapVolumeSource { + items: Some(vec![KeyToPath { + key: key.to_string(), + mode: Some(0o660), + path: file_type.filename.to_owned(), + }]), + name: name.to_string(), + ..ConfigMapVolumeSource::default() + }), + ..Volume::default() + }, + v1alpha1::SecuritySettingsFileTypeContent::ValueFrom( + v1alpha1::SecuritySettingsFileTypeContentValueFrom::SecretKeyRef( + v1alpha1::SecretKeyRef { name, key }, + ), + ) => Volume { + name: volume_name, + secret: Some(SecretVolumeSource { + items: Some(vec![KeyToPath { + key: key.to_string(), + mode: Some(0o660), + path: file_type.filename.to_owned(), + }]), + secret_name: Some(name.to_string()), + ..SecretVolumeSource::default() + }), + ..Volume::default() + }, + }; + + volumes.push(volume); + } + + volumes + } + + /// Builds the admin certificate volumes for the [`PodTemplateSpec`] + /// It is not checked if these volumes are required in this role group. + fn build_security_admin_cert_volumes(&self) -> Vec { + vec![Volume { + name: TLS_ADMIN_CERT_VOLUME_NAME.to_string(), + empty_dir: Some(EmptyDirVolumeSource { + size_limit: Some(Quantity(TLS_ADMIN_CERT_VOLUME_SIZE.to_owned())), + ..EmptyDirVolumeSource::default() + }), + ..Volume::default() + }] + } + + /// Builds the keystore volumes for the [`PodTemplateSpec`] + fn build_keystore_volumes(&self) -> Vec { + let mut volumes = vec![]; + + if !self.cluster.keystores.is_empty() { + volumes.push(Volume { + name: OPENSEARCH_KEYSTORE_VOLUME_NAME.to_string(), + empty_dir: Some(EmptyDirVolumeSource { + size_limit: Some(Quantity(OPENSEARCH_KEYSTORE_VOLUME_SIZE.to_owned())), + ..EmptyDirVolumeSource::default() + }), + ..Volume::default() + }) + } + + for (index, keystore) in self.cluster.keystores.iter().enumerate() { + volumes.push(Volume { + name: format!("keystore-{index}"), + secret: Some(SecretVolumeSource { + default_mode: Some(0o660), + secret_name: Some(keystore.secret_key_ref.name.to_string()), + items: Some(vec![KeyToPath { + key: keystore.secret_key_ref.key.to_string(), + path: keystore.secret_key_ref.key.to_string(), + ..KeyToPath::default() + }]), + ..SecretVolumeSource::default() + }), + ..Volume::default() + }); + } + + volumes + } + + /// Builds the headless [`Service`] for the role group + pub fn build_headless_service(&self) -> Service { + let metadata = self + .common_metadata(self.resource_names.headless_service_name()) + .with_labels(Self::prometheus_labels()) + .with_annotations(Self::prometheus_annotations( + self.security_mode.tls_server_secret_class().is_some(), + )) + .build(); + + let ports = vec![ + ServicePort { + name: Some(HTTP_PORT_NAME.to_owned()), + port: HTTP_PORT.into(), + ..ServicePort::default() + }, + ServicePort { + name: Some(TRANSPORT_PORT_NAME.to_owned()), + port: TRANSPORT_PORT.into(), + ..ServicePort::default() + }, + ]; + + let service_spec = ServiceSpec { + // Internal communication does not need to be exposed + type_: Some("ClusterIP".to_string()), + cluster_ip: Some("None".to_string()), + ports: Some(ports), + selector: Some(self.pod_selector().into()), + publish_not_ready_addresses: Some(true), + ..ServiceSpec::default() + }; + + Service { + metadata, + spec: Some(service_spec), + status: None, + } + } + + /// Common labels for Prometheus + fn prometheus_labels() -> Labels { + Labels::try_from([("prometheus.io/scrape", "true")]).expect("should be a valid label") + } + + /// Common annotations for Prometheus + /// + /// These annotations can be used in a ServiceMonitor. + /// + /// see also + fn prometheus_annotations(tls_on_http_port_enabled: bool) -> Annotations { + Annotations::try_from([ + ( + "prometheus.io/path".to_owned(), + "/_prometheus/metrics".to_owned(), + ), + ("prometheus.io/port".to_owned(), HTTP_PORT.to_string()), + ( + "prometheus.io/scheme".to_owned(), + if tls_on_http_port_enabled { + "https".to_owned() + } else { + "http".to_owned() + }, + ), ("prometheus.io/scrape".to_owned(), "true".to_owned()), ]) .expect("should be valid annotations") } - /// Builds the [`listener::v1alpha1::Listener`] for the role-group + /// Builds the [`listener::v1alpha1::Listener`] for the role group /// /// The Listener exposes only the HTTP port. - /// The Listener operator will create a Service per role-group. + /// The Listener operator will create a Service per role group. pub fn build_listener(&self) -> listener::v1alpha1::Listener { let metadata = self .common_metadata(self.resource_names.listener_name()) @@ -768,27 +1318,23 @@ cp --archive config/opensearch.keystore {OPENSEARCH_INITIALIZED_KEYSTORE_DIRECTO } } - /// Common metadata for role-group resources + /// Common metadata for role group resources fn common_metadata(&self, resource_name: impl Into) -> ObjectMetaBuilder { let mut builder = ObjectMetaBuilder::new(); builder .name(resource_name) .namespace(&self.cluster.namespace) - .ownerreference(ownerreference_from_resource( - &self.cluster, - None, - Some(true), - )) + .ownerreference(ownerreference_from_resource(self.cluster, None, Some(true))) .with_labels(self.recommended_labels()); builder } - /// Recommended labels for role-group resources + /// Recommended labels for role group resources fn recommended_labels(&self) -> Labels { recommended_labels( - &self.cluster, + self.cluster, &self.context_names.product_name, &self.cluster.product_version, &self.context_names.operator_name, @@ -798,48 +1344,17 @@ cp --archive config/opensearch.keystore {OPENSEARCH_INITIALIZED_KEYSTORE_DIRECTO ) } - /// Labels to select a [`Pod`] in the role-group + /// Labels to select a [`Pod`] in the role group /// /// [`Pod`]: stackable_operator::k8s_openapi::api::core::v1::Pod fn pod_selector(&self) -> Labels { role_group_selector( - &self.cluster, + self.cluster, &self.context_names.product_name, &ValidatedCluster::role_name(), &self.role_group_name, ) } - - fn build_tls_volume( - &self, - volume_name: &VolumeName, - tls_secret_class_name: &SecretClassName, - service_scopes: Vec, - secret_format: SecretFormat, - requested_secret_lifetime: &Duration, - listener_volume_scopes: Vec, - ) -> Volume { - let mut secret_volume_source_builder = - SecretOperatorVolumeSourceBuilder::new(tls_secret_class_name); - - for scope in service_scopes { - secret_volume_source_builder.with_service_scope(scope); - } - for scope in listener_volume_scopes { - secret_volume_source_builder.with_listener_volume_scope(scope); - } - - VolumeBuilder::new(volume_name.to_string()) - .ephemeral( - secret_volume_source_builder - .with_pod_scope() - .with_format(secret_format) - .with_auto_tls_cert_lifetime(*requested_secret_lifetime) - .build() - .expect("volume should be built without parse errors"), - ) - .build() - } } #[cfg(test)] @@ -850,6 +1365,7 @@ mod tests { }; use pretty_assertions::assert_eq; + use rstest::rstest; use serde_json::json; use stackable_operator::{ commons::{ @@ -871,207 +1387,1645 @@ mod tests { use crate::{ controller::{ ContextNames, OpenSearchRoleGroupConfig, ValidatedCluster, - ValidatedContainerLogConfigChoice, ValidatedLogging, ValidatedOpenSearchConfig, + ValidatedContainerLogConfigChoice, ValidatedLogging, ValidatedNodeRole, + ValidatedOpenSearchConfig, ValidatedSecurity, build::role_group_builder::{ DISCOVERY_SERVICE_LISTENER_VOLUME_NAME, OPENSEARCH_KEYSTORE_VOLUME_NAME, - TLS_INTERNAL_VOLUME_NAME, TLS_SERVER_VOLUME_NAME, + TLS_INTERNAL_VOLUME_NAME, TLS_SERVER_CA_VOLUME_NAME, TLS_SERVER_VOLUME_NAME, }, }, - crd::{NodeRoles, OpenSearchKeystoreKey, v1alpha1}, + crd::{OpenSearchKeystoreKey, v1alpha1}, framework::{ builder::pod::container::EnvVarSet, product_logging::framework::VectorContainerLogConfig, role_utils::GenericProductSpecificCommonConfig, types::{ kubernetes::{ - ConfigMapName, ListenerClassName, ListenerName, NamespaceName, SecretKey, - SecretName, ServiceAccountName, ServiceName, + ConfigMapKey, ConfigMapName, ListenerClassName, ListenerName, NamespaceName, + SecretClassName, SecretKey, SecretName, ServiceAccountName, ServiceName, + }, + operator::{ + ClusterName, ControllerName, OperatorName, ProductName, ProductVersion, + RoleGroupName, + }, + }, + }, + }; + + #[test] + fn test_constants() { + // Test that the functions do not panic + let _ = CONFIG_VOLUME_NAME; + let _ = LOG_CONFIG_VOLUME_NAME; + let _ = DATA_VOLUME_NAME; + let _ = ROLE_GROUP_LISTENER_VOLUME_NAME; + let _ = DISCOVERY_SERVICE_LISTENER_VOLUME_NAME; + let _ = TLS_SERVER_VOLUME_NAME; + let _ = TLS_SERVER_CA_VOLUME_NAME; + let _ = TLS_INTERNAL_VOLUME_NAME; + let _ = LOG_VOLUME_NAME; + let _ = OPENSEARCH_KEYSTORE_VOLUME_NAME; + } + + #[test] + fn test_security_settings_file_type_volume_name() { + let security_settings = v1alpha1::SecuritySettings::default(); + + for file_type in &security_settings { + // Test that the function does not panic + let _ = RoleGroupBuilder::security_settings_file_type_volume_name(&file_type); + } + } + + #[test] + fn test_security_settings_file_type_managed_by_env_var() { + let security_settings = v1alpha1::SecuritySettings::default(); + + for file_type in &security_settings { + // Test that the function does not panic + let _ = RoleGroupBuilder::security_settings_file_type_managed_by_env_var(&file_type); + } + } + + fn context_names() -> ContextNames { + ContextNames { + product_name: ProductName::from_str_unsafe("opensearch"), + operator_name: OperatorName::from_str_unsafe("opensearch.stackable.tech"), + controller_name: ControllerName::from_str_unsafe("opensearchcluster"), + cluster_domain_name: DomainName::from_str("cluster.local") + .expect("should be a valid domain name"), + } + } + + #[derive(Clone, Copy, Debug, Eq, PartialEq)] + enum TestSecurityMode { + Initializing, + Managing, + Participating, + Disabled, + } + + fn validated_cluster(security_mode: TestSecurityMode) -> ValidatedCluster { + let image = ResolvedProductImage { + product_version: "3.4.0".to_owned(), + app_version_label_value: LabelValue::from_str("3.4.0-stackable0.0.0-dev") + .expect("should be a valid label value"), + image: "oci.stackable.tech/sdp/opensearch:3.4.0-stackable0.0.0-dev".to_string(), + image_pull_policy: "Always".to_owned(), + pull_secrets: None, + }; + + let role_group_config = OpenSearchRoleGroupConfig { + replicas: 1, + config: ValidatedOpenSearchConfig { + affinity: StackableAffinity::default(), + discovery_service_exposed: true, + listener_class: ListenerClassName::from_str_unsafe("cluster-internal"), + logging: ValidatedLogging { + opensearch_container: ValidatedContainerLogConfigChoice::Automatic( + AutomaticContainerLogConfig::default(), + ), + vector_container: Some(VectorContainerLogConfig { + log_config: ValidatedContainerLogConfigChoice::Automatic( + AutomaticContainerLogConfig::default(), + ), + vector_aggregator_config_map_name: ConfigMapName::from_str_unsafe( + "vector-aggregator", + ), + }), + }, + node_roles: [ + ValidatedNodeRole::ClusterManager, + ValidatedNodeRole::Data, + ValidatedNodeRole::Ingest, + ValidatedNodeRole::RemoteClusterClient, + ] + .into(), + requested_secret_lifetime: Duration::from_str("1d") + .expect("should be a valid duration"), + resources: Resources::default(), + termination_grace_period_seconds: 30, + }, + config_overrides: HashMap::default(), + env_overrides: EnvVarSet::default(), + cli_overrides: BTreeMap::default(), + pod_overrides: PodTemplateSpec::default(), + product_specific_common_config: GenericProductSpecificCommonConfig::default(), + }; + + let security_settings = v1alpha1::SecuritySettings { + config: v1alpha1::SecuritySettingsFileType { + managed_by: v1alpha1::SecuritySettingsFileTypeManagedBy::Operator, + content: v1alpha1::SecuritySettingsFileTypeContent::Value( + v1alpha1::SecuritySettingsFileTypeContentValue { + value: json!({ + "_meta": { + "type": "config", + "config_version": 2 + }, + "config": { + "dynamic": { + "http": {}, + "authc": {}, + "authz": {} + } + } + }), + }, + ), + }, + internal_users: v1alpha1::SecuritySettingsFileType { + managed_by: v1alpha1::SecuritySettingsFileTypeManagedBy::Api, + content: v1alpha1::SecuritySettingsFileTypeContent::ValueFrom( + v1alpha1::SecuritySettingsFileTypeContentValueFrom::SecretKeyRef( + v1alpha1::SecretKeyRef { + name: SecretName::from_str_unsafe("opensearch-security-config"), + key: SecretKey::from_str_unsafe("internal_users.yml"), + }, + ), + ), + }, + roles: v1alpha1::SecuritySettingsFileType { + managed_by: v1alpha1::SecuritySettingsFileTypeManagedBy::Api, + content: v1alpha1::SecuritySettingsFileTypeContent::ValueFrom( + v1alpha1::SecuritySettingsFileTypeContentValueFrom::ConfigMapKeyRef( + v1alpha1::ConfigMapKeyRef { + name: ConfigMapName::from_str_unsafe("opensearch-security-config"), + key: ConfigMapKey::from_str_unsafe("roles.yml"), + }, + ), + ), + }, + ..v1alpha1::SecuritySettings::default() + }; + + let security = match security_mode { + TestSecurityMode::Initializing => ValidatedSecurity::ManagedByApi { + settings: security_settings, + tls_server_secret_class: Some(SecretClassName::from_str_unsafe("tls")), + tls_internal_secret_class: SecretClassName::from_str_unsafe("tls"), + }, + TestSecurityMode::Managing => ValidatedSecurity::ManagedByOperator { + managing_role_group: RoleGroupName::from_str_unsafe("default"), + settings: security_settings, + tls_server_secret_class: SecretClassName::from_str_unsafe("tls"), + tls_internal_secret_class: SecretClassName::from_str_unsafe("tls"), + }, + TestSecurityMode::Participating => ValidatedSecurity::ManagedByOperator { + managing_role_group: RoleGroupName::from_str_unsafe("other"), + settings: security_settings, + tls_server_secret_class: SecretClassName::from_str_unsafe("tls"), + tls_internal_secret_class: SecretClassName::from_str_unsafe("tls"), + }, + TestSecurityMode::Disabled => ValidatedSecurity::Disabled, + }; + + ValidatedCluster::new( + image.clone(), + ProductVersion::from_str_unsafe(&image.product_version), + ClusterName::from_str_unsafe("my-opensearch-cluster"), + NamespaceName::from_str_unsafe("default"), + uuid!("0b1e30e6-326e-4c1a-868d-ad6598b49e8b"), + v1alpha1::OpenSearchRoleConfig::default(), + [( + RoleGroupName::from_str_unsafe("default"), + role_group_config.clone(), + )] + .into(), + security, + vec![v1alpha1::OpenSearchKeystore { + key: OpenSearchKeystoreKey::from_str_unsafe("Keystore1"), + secret_key_ref: v1alpha1::SecretKeyRef { + name: SecretName::from_str_unsafe("my-keystore-secret"), + key: SecretKey::from_str_unsafe("my-keystore-file"), + }, + }], + None, + ) + } + + fn role_group_builder<'a>( + cluster: &'a ValidatedCluster, + context_names: &'a ContextNames, + ) -> RoleGroupBuilder<'a> { + let (role_group_name, role_group_config) = cluster + .role_group_configs + .first_key_value() + .expect("should be set"); + + let role_group_name = role_group_name.to_owned(); + let role_group_config = role_group_config.to_owned(); + + RoleGroupBuilder::new( + ServiceAccountName::from_str_unsafe("my-opensearch-cluster-serviceaccount"), + cluster, + role_group_name, + role_group_config, + context_names, + ServiceName::from_str_unsafe("my-opensearch-cluster-seed-nodes"), + ListenerName::from_str_unsafe("my-opensearch-cluster"), + ) + } + + #[rstest] + #[case::security_mode_initializing(TestSecurityMode::Initializing)] + #[case::security_mode_managing(TestSecurityMode::Managing)] + #[case::security_mode_participating(TestSecurityMode::Participating)] + #[case::security_mode_disabled(TestSecurityMode::Disabled)] + fn test_build_config_map(#[case] security_mode: TestSecurityMode) { + let cluster = validated_cluster(security_mode); + let context_names = context_names(); + let role_group_builder = role_group_builder(&cluster, &context_names); + + let mut config_map = serde_json::to_value(role_group_builder.build_config_map()) + .expect("should be serializable"); + + // The content of log4j2.properties is already tested in the + // `controller::build::product_logging::config` module. + config_map["data"]["log4j2.properties"].take(); + // The content of opensearch.yml is already tested in the `controller::build::node_config` + // module. + config_map["data"]["opensearch.yml"].take(); + // vector.yaml is a static file and does not have to be repeated here. + config_map["data"]["vector.yaml"].take(); + + let expected_data = match security_mode { + TestSecurityMode::Initializing | TestSecurityMode::Managing => json!({ + "action_groups.yml": "{\"_meta\":{\"config_version\":2,\"type\":\"actiongroups\"}}", + "allowlist.yml": "{\"_meta\":{\"config_version\":2,\"type\":\"allowlist\"},\"config\":{\"enabled\":false}}", + "audit.yml": "{\"_meta\":{\"config_version\":2,\"type\":\"audit\"},\"config\":{\"enabled\":false}}", + "config.yml": "{\"_meta\":{\"config_version\":2,\"type\":\"config\"},\"config\":{\"dynamic\":{\"authc\":{},\"authz\":{},\"http\":{}}}}", + "log4j2.properties": null, + "nodes_dn.yml": "{\"_meta\":{\"config_version\":2,\"type\":\"nodesdn\"}}", + "opensearch.yml": null, + "roles_mapping.yml": "{\"_meta\":{\"config_version\":2,\"type\":\"rolesmapping\"}}", + "tenants.yml": "{\"_meta\":{\"config_version\":2,\"type\":\"tenants\"}}", + "vector.yaml": null + }), + TestSecurityMode::Participating | TestSecurityMode::Disabled => json!({ + "log4j2.properties": null, + "opensearch.yml": null, + "vector.yaml": null + }), + }; + + assert_eq!( + json!({ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": { + "labels": { + "app.kubernetes.io/component": "nodes", + "app.kubernetes.io/instance": "my-opensearch-cluster", + "app.kubernetes.io/managed-by": "opensearch.stackable.tech_opensearchcluster", + "app.kubernetes.io/name": "opensearch", + "app.kubernetes.io/role-group": "default", + "app.kubernetes.io/version": "3.4.0", + "stackable.tech/vendor": "Stackable" + }, + "name": "my-opensearch-cluster-nodes-default", + "namespace": "default", + "ownerReferences": [ + { + "apiVersion": "opensearch.stackable.tech/v1alpha1", + "controller": true, + "kind": "OpenSearchCluster", + "name": "my-opensearch-cluster", + "uid": "0b1e30e6-326e-4c1a-868d-ad6598b49e8b" + } + ] + }, + "data": expected_data + }), + config_map + ); + } + + #[rstest] + #[case::security_mode_initializing(TestSecurityMode::Initializing)] + #[case::security_mode_managing(TestSecurityMode::Managing)] + #[case::security_mode_participating(TestSecurityMode::Participating)] + #[case::security_mode_disabled(TestSecurityMode::Disabled)] + fn test_build_stateful_set(#[case] security_mode: TestSecurityMode) { + let cluster = validated_cluster(security_mode); + let context_names = context_names(); + let role_group_builder = role_group_builder(&cluster, &context_names); + + let stateful_set = serde_json::to_value(role_group_builder.build_stateful_set()) + .expect("should be serializable"); + + let expected_opensearch_container_volume_mounts = match security_mode { + TestSecurityMode::Initializing => json!([ + { + "mountPath": "/stackable/opensearch/config/opensearch.yml", + "name": "config", + "readOnly": true, + "subPath": "opensearch.yml" + }, + { + "mountPath": "/stackable/opensearch/config/log4j2.properties", + "name": "log-config", + "readOnly": true, + "subPath": "log4j2.properties" + }, + { + "mountPath": "/stackable/opensearch/data", + "name": "data" + }, + { + "mountPath": "/stackable/listeners/role-group", + "name": "listener" + }, + { + "mountPath": "/stackable/log", + "name": "log" + }, + { + "mountPath": "/stackable/listeners/discovery-service", + "name": "discovery-service-listener" + }, + { + "mountPath": "/stackable/opensearch/config/tls/internal", + "name": "tls-internal" + }, + { + "mountPath": "/stackable/opensearch/config/tls/server", + "mountPath": "/stackable/opensearch/config/tls/server/tls.crt", + "name": "tls-server", + "subPath": "tls.crt" + }, + { + "mountPath": "/stackable/opensearch/config/tls/server/tls.key", + "name": "tls-server", + "subPath": "tls.key" + }, + { + "mountPath": "/stackable/opensearch/config/tls/server/ca.crt", + "name": "tls-server", + "subPath": "ca.crt" + }, + { + "mountPath": "/stackable/opensearch/config/opensearch-security/action_groups.yml", + "name": "security-config-file-actiongroups", + "readOnly": true, + "subPath": "action_groups.yml" + }, + { + "mountPath": "/stackable/opensearch/config/opensearch-security/allowlist.yml", + "name": "security-config-file-allowlist", + "readOnly": true, + "subPath": "allowlist.yml" + }, + { + "mountPath": "/stackable/opensearch/config/opensearch-security/audit.yml", + "name": "security-config-file-audit", + "readOnly": true, + "subPath": "audit.yml" + }, + { + "mountPath": "/stackable/opensearch/config/opensearch-security/config.yml", + "name": "security-config-file-config", + "readOnly": true, + "subPath": "config.yml" + }, + { + "mountPath": "/stackable/opensearch/config/opensearch-security/internal_users.yml", + "name": "security-config-file-internalusers", + "readOnly": true, + "subPath": "internal_users.yml" + }, + { + "mountPath": "/stackable/opensearch/config/opensearch-security/nodes_dn.yml", + "name": "security-config-file-nodesdn", + "readOnly": true, + "subPath": "nodes_dn.yml" + }, + { + "mountPath": "/stackable/opensearch/config/opensearch-security/roles.yml", + "name": "security-config-file-roles", + "readOnly": true, + "subPath": "roles.yml" + }, + { + "mountPath": "/stackable/opensearch/config/opensearch-security/roles_mapping.yml", + "name": "security-config-file-rolesmapping", + "readOnly": true, + "subPath": "roles_mapping.yml" + }, + { + "mountPath": "/stackable/opensearch/config/opensearch-security/tenants.yml", + "name": "security-config-file-tenants", + "readOnly": true, + "subPath": "tenants.yml" + }, + { + "mountPath": "/stackable/opensearch/config/opensearch.keystore", + "name": "keystore", + "readOnly": true, + "subPath": "opensearch.keystore" + } + ]), + TestSecurityMode::Managing => json!([ + { + "mountPath": "/stackable/opensearch/config/opensearch.yml", + "name": "config", + "readOnly": true, + "subPath": "opensearch.yml" + }, + { + "mountPath": "/stackable/opensearch/config/log4j2.properties", + "name": "log-config", + "readOnly": true, + "subPath": "log4j2.properties" + }, + { + "mountPath": "/stackable/opensearch/data", + "name": "data" + }, + { + "mountPath": "/stackable/listeners/role-group", + "name": "listener" + }, + { + "mountPath": "/stackable/log", + "name": "log" + }, + { + "mountPath": "/stackable/listeners/discovery-service", + "name": "discovery-service-listener" + }, + { + "mountPath": "/stackable/opensearch/config/tls/internal", + "name": "tls-internal" + }, + { + "mountPath": "/stackable/opensearch/config/tls/server", + "mountPath": "/stackable/opensearch/config/tls/server/tls.crt", + "name": "tls-server", + "subPath": "tls.crt" + }, + { + "mountPath": "/stackable/opensearch/config/tls/server/tls.key", + "name": "tls-server", + "subPath": "tls.key" + }, + { + "mountPath": "/stackable/opensearch/config/tls/server/ca.crt", + "name": "tls-server-ca", + "subPath": "ca.crt" + }, + { + "mountPath": "/stackable/opensearch/config/opensearch.keystore", + "name": "keystore", + "readOnly": true, + "subPath": "opensearch.keystore" + } + ]), + TestSecurityMode::Participating => json!([ + { + "mountPath": "/stackable/opensearch/config/opensearch.yml", + "name": "config", + "readOnly": true, + "subPath": "opensearch.yml" + }, + { + "mountPath": "/stackable/opensearch/config/log4j2.properties", + "name": "log-config", + "readOnly": true, + "subPath": "log4j2.properties" + }, + { + "mountPath": "/stackable/opensearch/data", + "name": "data" + }, + { + "mountPath": "/stackable/listeners/role-group", + "name": "listener" + }, + { + "mountPath": "/stackable/log", + "name": "log" + }, + { + "mountPath": "/stackable/listeners/discovery-service", + "name": "discovery-service-listener" + }, + { + "mountPath": "/stackable/opensearch/config/tls/internal", + "name": "tls-internal" + }, + { + "mountPath": "/stackable/opensearch/config/tls/server", + "mountPath": "/stackable/opensearch/config/tls/server/tls.crt", + "name": "tls-server", + "subPath": "tls.crt" + }, + { + "mountPath": "/stackable/opensearch/config/tls/server/tls.key", + "name": "tls-server", + "subPath": "tls.key" + }, + { + "mountPath": "/stackable/opensearch/config/tls/server/ca.crt", + "name": "tls-server", + "subPath": "ca.crt" + }, + { + "mountPath": "/stackable/opensearch/config/opensearch.keystore", + "name": "keystore", + "readOnly": true, + "subPath": "opensearch.keystore" + } + ]), + TestSecurityMode::Disabled => json!([ + { + "mountPath": "/stackable/opensearch/config/opensearch.yml", + "name": "config", + "readOnly": true, + "subPath": "opensearch.yml" + }, + { + "mountPath": "/stackable/opensearch/config/log4j2.properties", + "name": "log-config", + "readOnly": true, + "subPath": "log4j2.properties" + }, + { + "mountPath": "/stackable/opensearch/data", + "name": "data" + }, + { + "mountPath": "/stackable/listeners/role-group", + "name": "listener" + }, + { + "mountPath": "/stackable/log", + "name": "log" + }, + { + "mountPath": "/stackable/listeners/discovery-service", + "name": "discovery-service-listener" + }, + { + "mountPath": "/stackable/opensearch/config/opensearch.keystore", + "name": "keystore", + "readOnly": true, + "subPath": "opensearch.keystore" + } + ]), + }; + + let expected_opensearch_container = json!({ + "args": [ + concat!( + "\n", + "prepare_signal_handlers()\n", + "{\n", + " unset term_child_pid\n", + " unset term_kill_needed\n", + " trap 'handle_term_signal' TERM\n", + "}\n", + "\n", + "handle_term_signal()\n", + "{\n", + " if [ \"${term_child_pid}\" ]; then\n", + " kill -TERM \"${term_child_pid}\" 2>/dev/null\n", + " else\n", + " term_kill_needed=\"yes\"\n", + " fi\n", + "}\n", + "\n", + "wait_for_termination()\n", + "{\n", + " set +e\n", + " term_child_pid=$1\n", + " if [[ -v term_kill_needed ]]; then\n", + " kill -TERM \"${term_child_pid}\" 2>/dev/null\n", + " fi\n", + " wait ${term_child_pid} 2>/dev/null\n", + " trap - TERM\n", + " wait ${term_child_pid} 2>/dev/null\n", + " set -e\n", + "}\n", + "\n", + "rm -f /stackable/log/_vector/shutdown\n", + "prepare_signal_handlers\n", + "if command --search containerdebug >/dev/null 2>&1; then\n", + "containerdebug --output=/stackable/log/containerdebug-state.json --loop &\n", + "else\n", + "echo >&2 \"containerdebug not installed; Proceed without it.\"\n", + "fi\n", + "./opensearch-docker-entrypoint.sh &\n", + "wait_for_termination $!\n", + "mkdir -p /stackable/log/_vector && touch /stackable/log/_vector/shutdown" + ) + ], + "command": [ + "/bin/bash", + "-x", + "-euo", + "pipefail", + "-c" + ], + "env": [ + { + "name": "_POD_NAME", + "valueFrom": { + "fieldRef": { + "fieldPath": "metadata.name" + } + } + }, + { + "name": "discovery.seed_hosts", + "value": "my-opensearch-cluster-seed-nodes.default.svc.cluster.local" + }, + { + "name": "http.publish_host", + "value": "$(_POD_NAME).my-opensearch-cluster-nodes-default-headless.default.svc.cluster.local" + }, + { + "name": "network.publish_host", + "value": "$(_POD_NAME).my-opensearch-cluster-nodes-default-headless.default.svc.cluster.local" + }, + { + "name": "node.name", + "valueFrom": { + "fieldRef": { + "fieldPath": "metadata.name" + } + } + }, + { + "name": "node.roles", + "value": "cluster_manager,data,ingest,remote_cluster_client" + }, + { + "name": "transport.publish_host", + "value": "$(_POD_NAME).my-opensearch-cluster-nodes-default-headless.default.svc.cluster.local" + }, + ], + "image": "oci.stackable.tech/sdp/opensearch:3.4.0-stackable0.0.0-dev", + "imagePullPolicy": "Always", + "name": "opensearch", + "ports": [ + { + "containerPort": 9200, + "name": "http" + }, + { + "containerPort": 9300, + "name": "transport" + } + ], + "readinessProbe": { + "failureThreshold": 3, + "periodSeconds": 5, + "tcpSocket": { + "port": "http" + }, + "timeoutSeconds": 3 + }, + "resources": {}, + "startupProbe": { + "failureThreshold": 30, + "initialDelaySeconds": 5, + "periodSeconds": 10, + "tcpSocket": { + "port": "http" + }, + "timeoutSeconds": 3 + }, + "volumeMounts": expected_opensearch_container_volume_mounts + }); + + let expected_vector_container = json!({ + "args": [ + concat!( + "# Vector will ignore SIGTERM (as PID != 1) and must be shut down by writing a shutdown trigger file\n", + "vector & vector_pid=$!\n", + "if [ ! -f \"/stackable/log/_vector/shutdown\" ]; then\n", + "mkdir -p /stackable/log/_vector\n", + "inotifywait -qq --event create /stackable/log/_vector;\n", + "fi\n", + "sleep 1\n", + "kill $vector_pid" + ), + ], + "command": [ + "/bin/bash", + "-x", + "-euo", + "pipefail", + "-c" + ], + "env": [ + { + "name": "CLUSTER_NAME", + "value":"my-opensearch-cluster", + }, + { + "name": "LOG_DIR", + "value": "/stackable/log", + }, + { + "name": "NAMESPACE", + "valueFrom": { + "fieldRef": { + "fieldPath": "metadata.namespace", + }, + }, + }, + { + "name": "OPENSEARCH_SERVER_LOG_FILE", + "value": "opensearch_server.json", + }, + { + "name": "ROLE_GROUP_NAME", + "value": "default", + }, + { + "name": "ROLE_NAME", + "value": "nodes", + }, + { + "name": "VECTOR_AGGREGATOR_ADDRESS", + "valueFrom": { + "configMapKeyRef": { + "key": "ADDRESS", + "name": "vector-aggregator", + }, + }, + }, + { + "name": "VECTOR_CONFIG_YAML", + "value": "/stackable/config/vector.yaml", + }, + { + "name": "VECTOR_FILE_LOG_LEVEL", + "value": "info", + }, + { + "name": "VECTOR_LOG", + "value": "info", + }, + ], + "image": "oci.stackable.tech/sdp/opensearch:3.4.0-stackable0.0.0-dev", + "imagePullPolicy": "Always", + "name": "vector", + "resources": { + "limits": { + "cpu": "500m", + "memory": "128Mi", + }, + "requests": { + "cpu": "250m", + "memory": "128Mi", + }, + }, + "volumeMounts": [ + { + "mountPath": "/stackable/config/vector.yaml", + "name": "config", + "readOnly": true, + "subPath": "vector.yaml", + }, + { + "mountPath": "/stackable/log", + "name": "log", + }, + ], + }); + + let expected_update_security_config_container = json!({ + "args": [ + include_str!("scripts/update-security-config.sh") + ], + "command": [ + "/bin/bash", + "-c", + ], + "env": [ + { + "name": "MANAGE_ACTIONGROUPS", + "value": "false", + }, + { + "name": "MANAGE_ALLOWLIST", + "value": "false", + }, + { + "name": "MANAGE_AUDIT", + "value": "false", + }, + { + "name": "MANAGE_CONFIG", + "value": "true", + }, + { + "name": "MANAGE_INTERNALUSERS", + "value": "false", + }, + { + "name": "MANAGE_NODESDN", + "value": "false", + }, + { + "name": "MANAGE_ROLES", + "value": "false", + }, + { + "name": "MANAGE_ROLESMAPPING", + "value": "false", + }, + { + "name": "MANAGE_TENANTS", + "value": "false", + }, + { + "name": "OPENSEARCH_PATH_CONF", + "value": "/stackable/opensearch/config", + }, + { + "name": "POD_NAME", + "valueFrom": { + "fieldRef": { + "fieldPath": "metadata.name", + }, + }, + }, + ], + "image": "oci.stackable.tech/sdp/opensearch:3.4.0-stackable0.0.0-dev", + "imagePullPolicy": "Always", + "name": "update-security-config", + "resources": { + "limits": { + "cpu": "400m", + "memory": "512Mi", + }, + "requests": { + "cpu": "100m", + "memory": "512Mi", + }, + }, + "volumeMounts": [ + { + "mountPath": "/stackable/opensearch/config/tls/tls.crt", + "name": "tls-admin-cert", + "readOnly": true, + "subPath": "tls.crt", + }, + { + "mountPath": "/stackable/opensearch/config/tls/tls.key", + "name": "tls-admin-cert", + "readOnly": true, + "subPath": "tls.key", + }, + { + "mountPath": "/stackable/opensearch/config/tls/ca.crt", + "name": "tls-server-ca", + "readOnly": true, + "subPath": "ca.crt", + }, + { + "mountPath": "/stackable/log", + "name": "log", + }, + { + "mountPath": "/stackable/opensearch/config/opensearch-security/action_groups.yml", + "name": "security-config-file-actiongroups", + "readOnly": true, + "subPath": "action_groups.yml", + }, + { + "mountPath": "/stackable/opensearch/config/opensearch-security/allowlist.yml", + "name": "security-config-file-allowlist", + "readOnly": true, + "subPath": "allowlist.yml", + }, + { + "mountPath": "/stackable/opensearch/config/opensearch-security/audit.yml", + "name": "security-config-file-audit", + "readOnly": true, + "subPath": "audit.yml", + }, + { + "mountPath": "/stackable/opensearch/config/opensearch-security/config.yml", + "name": "security-config-file-config", + "readOnly": true, + "subPath": "config.yml", + }, + { + "mountPath": "/stackable/opensearch/config/opensearch-security/internal_users.yml", + "name": "security-config-file-internalusers", + "readOnly": true, + "subPath": "internal_users.yml", + }, + { + "mountPath": "/stackable/opensearch/config/opensearch-security/nodes_dn.yml", + "name": "security-config-file-nodesdn", + "readOnly": true, + "subPath": "nodes_dn.yml", + }, + { + "mountPath": "/stackable/opensearch/config/opensearch-security/roles.yml", + "name": "security-config-file-roles", + "readOnly": true, + "subPath": "roles.yml", + }, + { + "mountPath": "/stackable/opensearch/config/opensearch-security/roles_mapping.yml", + "name": "security-config-file-rolesmapping", + "readOnly": true, + "subPath": "roles_mapping.yml", + }, + { + "mountPath": "/stackable/opensearch/config/opensearch-security/tenants.yml", + "name": "security-config-file-tenants", + "readOnly": true, + "subPath": "tenants.yml", + }, + ], + }); + + let expected_containers = match security_mode { + TestSecurityMode::Initializing => { + json!([expected_opensearch_container, expected_vector_container]) + } + TestSecurityMode::Managing => { + json!([ + expected_opensearch_container, + expected_vector_container, + expected_update_security_config_container + ]) + } + TestSecurityMode::Participating => { + json!([expected_opensearch_container, expected_vector_container]) + } + TestSecurityMode::Disabled => { + json!([expected_opensearch_container, expected_vector_container]) + } + }; + + let expected_init_keystore_container = json!({ + "args": [ + include_str!("scripts/init-keystore.sh") + ], + "command": [ + "/bin/bash", + "-c" + ], + "image": "oci.stackable.tech/sdp/opensearch:3.4.0-stackable0.0.0-dev", + "imagePullPolicy": "Always", + "name": "init-keystore", + "resources": {}, + "volumeMounts": [ + { + "mountPath": "/stackable/opensearch/initialized-keystore", + "name": "keystore", + }, + { + "mountPath": "/stackable/opensearch/keystore-secrets/Keystore1", + "name": "keystore-0", + "readOnly": true, + "subPath": "my-keystore-file" + } + ] + }); + + let expected_create_admin_certificate_container = json!({ + "args": [ + include_str!("scripts/create-admin-certificate.sh") + ], + "command": [ + "/bin/bash", + "-c", + ], + "env": [ + { + "name": "ADMIN_DN", + "value": "CN=update-security-config.0b1e30e6-326e-4c1a-868d-ad6598b49e8b", + }, + { + "name": "POD_NAME", + "valueFrom": { + "fieldRef": { + "fieldPath": "metadata.name", + }, + }, + }, + ], + "image": "oci.stackable.tech/sdp/opensearch:3.4.0-stackable0.0.0-dev", + "imagePullPolicy": "Always", + "name": "create-admin-certificate", + "resources": { + "limits": { + "cpu": "400m", + "memory": "128Mi", + }, + "requests": { + "cpu": "100m", + "memory": "128Mi", + }, + }, + "volumeMounts": [ + { + "mountPath": "/stackable/tls-server/ca.crt", + "name": "tls-server", + "readOnly": true, + "subPath": "ca.crt", + }, + { + "mountPath": "/stackable/tls-admin-cert", + "name": "tls-admin-cert", + "readOnly": false, + }, + { + "mountPath": "/stackable/tls-server-ca", + "name": "tls-server-ca", + "readOnly": false, + }, + ], + }); + + let expected_init_containers = match security_mode { + TestSecurityMode::Initializing => json!([expected_init_keystore_container]), + TestSecurityMode::Managing => json!([ + expected_init_keystore_container, + expected_create_admin_certificate_container + ]), + TestSecurityMode::Participating => json!([expected_init_keystore_container]), + TestSecurityMode::Disabled => json!([expected_init_keystore_container]), + }; + + let expected_volumes = match security_mode { + TestSecurityMode::Initializing => json!([ + { + "configMap": { + "defaultMode": 0o660, + "name": "my-opensearch-cluster-nodes-default" + }, + "name": "config" + }, + { + "configMap": { + "defaultMode": 0o660, + "name": "my-opensearch-cluster-nodes-default" + }, + "name": "log-config" + }, + { + "emptyDir": { + "sizeLimit": "30Mi" + }, + "name": "log" + }, + { + "ephemeral": { + "volumeClaimTemplate": { + "metadata": { + "annotations": { + "secrets.stackable.tech/backend.autotls.cert.lifetime": "1d", + "secrets.stackable.tech/class": "tls", + "secrets.stackable.tech/format": "tls-pem", + "secrets.stackable.tech/scope": "pod,listener-volume=listener,service=my-opensearch-cluster-seed-nodes" + } + }, + "spec": { + "accessModes": [ + "ReadWriteOnce" + ], + "resources": { + "requests": { + "storage": "1" + } + }, + "storageClassName": "secrets.stackable.tech" + } + } + }, + "name": "tls-internal" + }, + { + "ephemeral": { + "volumeClaimTemplate": { + "metadata": { + "annotations": { + "secrets.stackable.tech/backend.autotls.cert.lifetime": "1d", + "secrets.stackable.tech/class": "tls", + "secrets.stackable.tech/format": "tls-pem", + "secrets.stackable.tech/scope": "pod,listener-volume=listener,listener-volume=discovery-service-listener" + } + }, + "spec": { + "accessModes": [ + "ReadWriteOnce" + ], + "resources": { + "requests": { + "storage": "1" + } + }, + "storageClassName": "secrets.stackable.tech" + } + } + }, + "name": "tls-server" + }, + { + "configMap": { + "items": [ + { + "key": "action_groups.yml", + "mode": 0o660, + "path": "action_groups.yml" + } + ], + "name": "my-opensearch-cluster-nodes-default" + }, + "name": "security-config-file-actiongroups" + }, + { + "configMap": { + "items": [ + { + "key": "allowlist.yml", + "mode": 0o660, + "path": "allowlist.yml" + } + ], + "name": "my-opensearch-cluster-nodes-default" + }, + "name": "security-config-file-allowlist" + }, + { + "configMap": { + "items": [ + { + "key": "audit.yml", + "mode": 0o660, + "path": "audit.yml" + } + ], + "name": "my-opensearch-cluster-nodes-default" + }, + "name": "security-config-file-audit" + }, + { + "configMap": { + "items": [ + { + "key": "config.yml", + "mode": 0o660, + "path": "config.yml" + } + ], + "name": "my-opensearch-cluster-nodes-default" + }, + "name": "security-config-file-config" + }, + { + "name": "security-config-file-internalusers", + "secret": { + "items": [ + { + "key": "internal_users.yml", + "mode": 0o660, + "path": "internal_users.yml" + } + ], + "secretName": "opensearch-security-config" + } + }, + { + "configMap": { + "items": [ + { + "key": "nodes_dn.yml", + "mode": 0o660, + "path": "nodes_dn.yml" + } + ], + "name": "my-opensearch-cluster-nodes-default" + }, + "name": "security-config-file-nodesdn" + }, + { + "configMap": { + "items": [ + { + "key": "roles.yml", + "mode": 0o660, + "path": "roles.yml" + } + ], + "name": "opensearch-security-config" + }, + "name": "security-config-file-roles" + }, + { + "configMap": { + "items": [ + { + "key": "roles_mapping.yml", + "mode": 0o660, + "path": "roles_mapping.yml" + } + ], + "name": "my-opensearch-cluster-nodes-default" + }, + "name": "security-config-file-rolesmapping" + }, + { + "configMap": { + "items": [ + { + "key": "tenants.yml", + "mode": 0o660, + "path": "tenants.yml" + } + ], + "name": "my-opensearch-cluster-nodes-default" + }, + "name": "security-config-file-tenants" + }, + { + "emptyDir": { + "sizeLimit": "1Mi" + }, + "name": "keystore" + }, + { + "name": "keystore-0", + "secret": { + "defaultMode": 0o660, + "items": [ + { + "key": "my-keystore-file", + "path": "my-keystore-file" + } + ], + "secretName": "my-keystore-secret" + } + } + ]), + TestSecurityMode::Managing => json!([ + { + "configMap": { + "defaultMode": 0o660, + "name": "my-opensearch-cluster-nodes-default" + }, + "name": "config" + }, + { + "configMap": { + "defaultMode": 0o660, + "name": "my-opensearch-cluster-nodes-default" + }, + "name": "log-config" + }, + { + "emptyDir": { + "sizeLimit": "30Mi" + }, + "name": "log" + }, + { + "ephemeral": { + "volumeClaimTemplate": { + "metadata": { + "annotations": { + "secrets.stackable.tech/backend.autotls.cert.lifetime": "1d", + "secrets.stackable.tech/class": "tls", + "secrets.stackable.tech/format": "tls-pem", + "secrets.stackable.tech/scope": "pod,listener-volume=listener,service=my-opensearch-cluster-seed-nodes" + } + }, + "spec": { + "accessModes": [ + "ReadWriteOnce" + ], + "resources": { + "requests": { + "storage": "1" + } + }, + "storageClassName": "secrets.stackable.tech" + } + } + }, + "name": "tls-internal" + }, + { + "ephemeral": { + "volumeClaimTemplate": { + "metadata": { + "annotations": { + "secrets.stackable.tech/backend.autotls.cert.lifetime": "1d", + "secrets.stackable.tech/class": "tls", + "secrets.stackable.tech/format": "tls-pem", + "secrets.stackable.tech/scope": "pod,listener-volume=listener,listener-volume=discovery-service-listener" + } + }, + "spec": { + "accessModes": [ + "ReadWriteOnce" + ], + "resources": { + "requests": { + "storage": "1" + } + }, + "storageClassName": "secrets.stackable.tech" + } + } + }, + "name": "tls-server" + }, + { + "configMap": { + "items": [ + { + "key": "action_groups.yml", + "mode": 0o660, + "path": "action_groups.yml" + } + ], + "name": "my-opensearch-cluster-nodes-default" + }, + "name": "security-config-file-actiongroups" + }, + { + "configMap": { + "items": [ + { + "key": "allowlist.yml", + "mode": 0o660, + "path": "allowlist.yml" + } + ], + "name": "my-opensearch-cluster-nodes-default" + }, + "name": "security-config-file-allowlist" + }, + { + "configMap": { + "items": [ + { + "key": "audit.yml", + "mode": 0o660, + "path": "audit.yml" + } + ], + "name": "my-opensearch-cluster-nodes-default" + }, + "name": "security-config-file-audit" + }, + { + "configMap": { + "items": [ + { + "key": "config.yml", + "mode": 0o660, + "path": "config.yml" + } + ], + "name": "my-opensearch-cluster-nodes-default" + }, + "name": "security-config-file-config" }, - operator::{ - ClusterName, ControllerName, OperatorName, ProductName, ProductVersion, - RoleGroupName, + { + "name": "security-config-file-internalusers", + "secret": { + "items": [ + { + "key": "internal_users.yml", + "mode": 0o660, + "path": "internal_users.yml" + } + ], + "secretName": "opensearch-security-config" + } }, - }, - }, - }; - - #[test] - fn test_constants() { - // Test that the functions do not panic - let _ = CONFIG_VOLUME_NAME; - let _ = LOG_CONFIG_VOLUME_NAME; - let _ = DATA_VOLUME_NAME; - let _ = ROLE_GROUP_LISTENER_VOLUME_NAME; - let _ = DISCOVERY_SERVICE_LISTENER_VOLUME_NAME; - let _ = TLS_SERVER_VOLUME_NAME; - let _ = TLS_INTERNAL_VOLUME_NAME; - let _ = LOG_VOLUME_NAME; - let _ = OPENSEARCH_KEYSTORE_VOLUME_NAME; - } - - fn context_names() -> ContextNames { - ContextNames { - product_name: ProductName::from_str_unsafe("opensearch"), - operator_name: OperatorName::from_str_unsafe("opensearch.stackable.tech"), - controller_name: ControllerName::from_str_unsafe("opensearchcluster"), - cluster_domain_name: DomainName::from_str("cluster.local") - .expect("should be a valid domain name"), - } - } - - fn validated_cluster() -> ValidatedCluster { - let image = ResolvedProductImage { - product_version: "3.4.0".to_owned(), - app_version_label_value: LabelValue::from_str("3.4.0-stackable0.0.0-dev") - .expect("should be a valid label value"), - image: "oci.stackable.tech/sdp/opensearch:3.4.0-stackable0.0.0-dev".to_string(), - image_pull_policy: "Always".to_owned(), - pull_secrets: None, - }; - - let role_group_config = OpenSearchRoleGroupConfig { - replicas: 1, - config: ValidatedOpenSearchConfig { - affinity: StackableAffinity::default(), - discovery_service_exposed: true, - listener_class: ListenerClassName::from_str_unsafe("cluster-internal"), - logging: ValidatedLogging { - opensearch_container: ValidatedContainerLogConfigChoice::Automatic( - AutomaticContainerLogConfig::default(), - ), - vector_container: Some(VectorContainerLogConfig { - log_config: ValidatedContainerLogConfigChoice::Automatic( - AutomaticContainerLogConfig::default(), - ), - vector_aggregator_config_map_name: ConfigMapName::from_str_unsafe( - "vector-aggregator", - ), - }), + { + "configMap": { + "items": [ + { + "key": "nodes_dn.yml", + "mode": 0o660, + "path": "nodes_dn.yml" + } + ], + "name": "my-opensearch-cluster-nodes-default" + }, + "name": "security-config-file-nodesdn" }, - node_roles: NodeRoles(vec![ - v1alpha1::NodeRole::ClusterManager, - v1alpha1::NodeRole::Data, - v1alpha1::NodeRole::Ingest, - v1alpha1::NodeRole::RemoteClusterClient, - ]), - requested_secret_lifetime: Duration::from_str("1d") - .expect("should be a valid duration"), - resources: Resources::default(), - termination_grace_period_seconds: 30, - }, - config_overrides: HashMap::default(), - env_overrides: EnvVarSet::default(), - cli_overrides: BTreeMap::default(), - pod_overrides: PodTemplateSpec::default(), - product_specific_common_config: GenericProductSpecificCommonConfig::default(), - }; - - ValidatedCluster::new( - image.clone(), - ProductVersion::from_str_unsafe(&image.product_version), - ClusterName::from_str_unsafe("my-opensearch-cluster"), - NamespaceName::from_str_unsafe("default"), - uuid!("0b1e30e6-326e-4c1a-868d-ad6598b49e8b"), - v1alpha1::OpenSearchRoleConfig::default(), - [( - RoleGroupName::from_str_unsafe("default"), - role_group_config.clone(), - )] - .into(), - v1alpha1::OpenSearchTls::default(), - vec![v1alpha1::OpenSearchKeystore { - key: OpenSearchKeystoreKey::from_str_unsafe("Keystore1"), - secret_key_ref: v1alpha1::SecretKeyRef { - name: SecretName::from_str_unsafe("my-keystore-secret"), - key: SecretKey::from_str_unsafe("my-keystore-file"), + { + "configMap": { + "items": [ + { + "key": "roles.yml", + "mode": 0o660, + "path": "roles.yml" + } + ], + "name": "opensearch-security-config" + }, + "name": "security-config-file-roles" }, - }], - None, - ) - } - - fn role_group_builder<'a>(context_names: &'a ContextNames) -> RoleGroupBuilder<'a> { - let cluster = validated_cluster(); - - let (role_group_name, role_group_config) = cluster - .role_group_configs - .first_key_value() - .expect("should be set"); - - let role_group_name = role_group_name.to_owned(); - let role_group_config = role_group_config.to_owned(); - - RoleGroupBuilder::new( - ServiceAccountName::from_str_unsafe("my-opensearch-cluster-serviceaccount"), - cluster, - role_group_name, - role_group_config, - context_names, - ServiceName::from_str_unsafe("my-opensearch-cluster-seed-nodes"), - ListenerName::from_str_unsafe("my-opensearch-cluster"), - ) - } - - #[test] - fn test_build_config_map() { - let context_names = context_names(); - let role_group_builder = role_group_builder(&context_names); - - let mut config_map = serde_json::to_value(role_group_builder.build_config_map()) - .expect("should be serializable"); - - // The content of log4j2.properties is already tested in the - // `controller::build::product_logging::config` module. - config_map["data"]["log4j2.properties"].take(); - // The content of opensearch.yml is already tested in the `controller::build::node_config` - // module. - config_map["data"]["opensearch.yml"].take(); - // vector.yaml is a static file and does not have to be repeated here. - config_map["data"]["vector.yaml"].take(); - - assert_eq!( - json!({ - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": { - "labels": { - "app.kubernetes.io/component": "nodes", - "app.kubernetes.io/instance": "my-opensearch-cluster", - "app.kubernetes.io/managed-by": "opensearch.stackable.tech_opensearchcluster", - "app.kubernetes.io/name": "opensearch", - "app.kubernetes.io/role-group": "default", - "app.kubernetes.io/version": "3.4.0", - "stackable.tech/vendor": "Stackable" + { + "configMap": { + "items": [ + { + "key": "roles_mapping.yml", + "mode": 0o660, + "path": "roles_mapping.yml" + } + ], + "name": "my-opensearch-cluster-nodes-default" }, - "name": "my-opensearch-cluster-nodes-default", - "namespace": "default", - "ownerReferences": [ - { - "apiVersion": "opensearch.stackable.tech/v1alpha1", - "controller": true, - "kind": "OpenSearchCluster", - "name": "my-opensearch-cluster", - "uid": "0b1e30e6-326e-4c1a-868d-ad6598b49e8b" + "name": "security-config-file-rolesmapping" + }, + { + "configMap": { + "items": [ + { + "key": "tenants.yml", + "mode": 0o660, + "path": "tenants.yml" + } + ], + "name": "my-opensearch-cluster-nodes-default" + }, + "name": "security-config-file-tenants" + }, + { + "emptyDir": { + "sizeLimit": "1Mi", + }, + "name": "tls-server-ca", + }, + { + "emptyDir": { + "sizeLimit": "1Mi", + }, + "name": "tls-admin-cert", + }, + { + "emptyDir": { + "sizeLimit": "1Mi" + }, + "name": "keystore" + }, + { + "name": "keystore-0", + "secret": { + "defaultMode": 0o660, + "items": [ + { + "key": "my-keystore-file", + "path": "my-keystore-file" + } + ], + "secretName": "my-keystore-secret" + } + } + ]), + TestSecurityMode::Participating => json!([ + { + "configMap": { + "defaultMode": 0o660, + "name": "my-opensearch-cluster-nodes-default" + }, + "name": "config" + }, + { + "configMap": { + "defaultMode": 0o660, + "name": "my-opensearch-cluster-nodes-default" + }, + "name": "log-config" + }, + { + "emptyDir": { + "sizeLimit": "30Mi" + }, + "name": "log" + }, + { + "ephemeral": { + "volumeClaimTemplate": { + "metadata": { + "annotations": { + "secrets.stackable.tech/backend.autotls.cert.lifetime": "1d", + "secrets.stackable.tech/class": "tls", + "secrets.stackable.tech/format": "tls-pem", + "secrets.stackable.tech/scope": "pod,listener-volume=listener,service=my-opensearch-cluster-seed-nodes" + } + }, + "spec": { + "accessModes": [ + "ReadWriteOnce" + ], + "resources": { + "requests": { + "storage": "1" + } + }, + "storageClassName": "secrets.stackable.tech" + } } - ] + }, + "name": "tls-internal" + }, + { + "ephemeral": { + "volumeClaimTemplate": { + "metadata": { + "annotations": { + "secrets.stackable.tech/backend.autotls.cert.lifetime": "1d", + "secrets.stackable.tech/class": "tls", + "secrets.stackable.tech/format": "tls-pem", + "secrets.stackable.tech/scope": "pod,listener-volume=listener,listener-volume=discovery-service-listener" + } + }, + "spec": { + "accessModes": [ + "ReadWriteOnce" + ], + "resources": { + "requests": { + "storage": "1" + } + }, + "storageClassName": "secrets.stackable.tech" + } + } + }, + "name": "tls-server" + }, + { + "emptyDir": { + "sizeLimit": "1Mi" + }, + "name": "keystore" }, - "data": { - "log4j2.properties": null, - "opensearch.yml": null, - "vector.yaml": null + { + "name": "keystore-0", + "secret": { + "defaultMode": 0o660, + "items": [ + { + "key": "my-keystore-file", + "path": "my-keystore-file" + } + ], + "secretName": "my-keystore-secret" + } } - }), - config_map - ); - } - - #[test] - fn test_build_stateful_set() { - let context_names = context_names(); - let role_group_builder = role_group_builder(&context_names); - - let stateful_set = serde_json::to_value(role_group_builder.build_stateful_set()) - .expect("should be serializable"); + ]), + TestSecurityMode::Disabled => json!([ + { + "configMap": { + "defaultMode": 0o660, + "name": "my-opensearch-cluster-nodes-default" + }, + "name": "config" + }, + { + "configMap": { + "defaultMode": 0o660, + "name": "my-opensearch-cluster-nodes-default" + }, + "name": "log-config" + }, + { + "emptyDir": { + "sizeLimit": "30Mi" + }, + "name": "log" + }, + { + "emptyDir": { + "sizeLimit": "1Mi" + }, + "name": "keystore" + }, + { + "name": "keystore-0", + "secret": { + "defaultMode": 0o660, + "items": [ + { + "key": "my-keystore-file", + "path": "my-keystore-file" + } + ], + "secretName": "my-keystore-secret" + } + } + ]), + }; assert_eq!( json!({ @@ -1133,406 +3087,14 @@ mod tests { }, "spec": { "affinity": {}, - "containers": [ - { - "args": [ - concat!( - "\n", - "prepare_signal_handlers()\n", - "{\n", - " unset term_child_pid\n", - " unset term_kill_needed\n", - " trap 'handle_term_signal' TERM\n", - "}\n", - "\n", - "handle_term_signal()\n", - "{\n", - " if [ \"${term_child_pid}\" ]; then\n", - " kill -TERM \"${term_child_pid}\" 2>/dev/null\n", - " else\n", - " term_kill_needed=\"yes\"\n", - " fi\n", - "}\n", - "\n", - "wait_for_termination()\n", - "{\n", - " set +e\n", - " term_child_pid=$1\n", - " if [[ -v term_kill_needed ]]; then\n", - " kill -TERM \"${term_child_pid}\" 2>/dev/null\n", - " fi\n", - " wait ${term_child_pid} 2>/dev/null\n", - " trap - TERM\n", - " wait ${term_child_pid} 2>/dev/null\n", - " set -e\n", - "}\n", - "\n", - "rm -f /stackable/log/_vector/shutdown\n", - "prepare_signal_handlers\n", - "if command --search containerdebug >/dev/null 2>&1; then\n", - "containerdebug --output=/stackable/log/containerdebug-state.json --loop &\n", - "else\n", - "echo >&2 \"containerdebug not installed; Proceed without it.\"\n", - "fi\n", - "./opensearch-docker-entrypoint.sh &\n", - "wait_for_termination $!\n", - "mkdir -p /stackable/log/_vector && touch /stackable/log/_vector/shutdown" - ) - ], - "command": [ - "/bin/bash", - "-x", - "-euo", - "pipefail", - "-c" - ], - "env": [ - { - "name": "_POD_NAME", - "valueFrom": { - "fieldRef": { - "fieldPath": "metadata.name" - } - } - }, - { - "name": "discovery.seed_hosts", - "value": "my-opensearch-cluster-seed-nodes.default.svc.cluster.local" - }, - { - "name": "http.publish_host", - "value": "$(_POD_NAME).my-opensearch-cluster-nodes-default-headless.default.svc.cluster.local" - }, - { - "name": "network.publish_host", - "value": "$(_POD_NAME).my-opensearch-cluster-nodes-default-headless.default.svc.cluster.local" - }, - { - "name": "node.name", - "valueFrom": { - "fieldRef": { - "fieldPath": "metadata.name" - } - } - }, - { - "name": "node.roles", - "value": "cluster_manager,data,ingest,remote_cluster_client" - }, - { - "name": "transport.publish_host", - "value": "$(_POD_NAME).my-opensearch-cluster-nodes-default-headless.default.svc.cluster.local" - }, - ], - "image": "oci.stackable.tech/sdp/opensearch:3.4.0-stackable0.0.0-dev", - "imagePullPolicy": "Always", - "name": "opensearch", - "ports": [ - { - "containerPort": 9200, - "name": "http" - }, - { - "containerPort": 9300, - "name": "transport" - } - ], - "readinessProbe": { - "failureThreshold": 3, - "periodSeconds": 5, - "tcpSocket": { - "port": "http" - }, - "timeoutSeconds": 3 - }, - "resources": {}, - "startupProbe": { - "failureThreshold": 30, - "initialDelaySeconds": 5, - "periodSeconds": 10, - "tcpSocket": { - "port": "http" - }, - "timeoutSeconds": 3 - }, - "volumeMounts": [ - { - "mountPath": "/stackable/opensearch/config/opensearch.yml", - "name": "config", - "readOnly": true, - "subPath": "opensearch.yml" - }, - { - "mountPath": "/stackable/opensearch/config/log4j2.properties", - "name": "log-config", - "readOnly": true, - "subPath": "log4j2.properties" - }, - { - "mountPath": "/stackable/opensearch/data", - "name": "data" - }, - { - "mountPath": "/stackable/listeners/role-group", - "name": "listener" - }, - { - "mountPath": "/stackable/log", - "name": "log" - }, - { - "mountPath": "/stackable/opensearch/config/tls/internal", - "name": "tls-internal" - }, - { - "mountPath": "/stackable/listeners/discovery-service", - "name": "discovery-service-listener" - }, - { - "mountPath": "/stackable/opensearch/config/tls/server", - "name": "tls-server", - }, - { - "mountPath": "/stackable/opensearch/config/opensearch.keystore", - "name": "keystore", - "readOnly": true, - "subPath": "opensearch.keystore", - } - ] - }, - { - "args": [ - concat!( - "# Vector will ignore SIGTERM (as PID != 1) and must be shut down by writing a shutdown trigger file\n", - "vector & vector_pid=$!\n", - "if [ ! -f \"/stackable/log/_vector/shutdown\" ]; then\n", - "mkdir -p /stackable/log/_vector\n", - "inotifywait -qq --event create /stackable/log/_vector;\n", - "fi\n", - "sleep 1\n", - "kill $vector_pid" - ), - ], - "command": [ - "/bin/bash", - "-x", - "-euo", - "pipefail", - "-c" - ], - "env": [ - { - "name": "CLUSTER_NAME", - "value":"my-opensearch-cluster", - }, - { - "name": "LOG_DIR", - "value": "/stackable/log", - }, - { - "name": "NAMESPACE", - "valueFrom": { - "fieldRef": { - "fieldPath": "metadata.namespace", - }, - }, - }, - { - "name": "OPENSEARCH_SERVER_LOG_FILE", - "value": "opensearch_server.json", - }, - { - "name": "ROLE_GROUP_NAME", - "value": "default", - }, - { - "name": "ROLE_NAME", - "value": "nodes", - }, - { - "name": "VECTOR_AGGREGATOR_ADDRESS", - "valueFrom": { - "configMapKeyRef": { - "key": "ADDRESS", - "name": "vector-aggregator", - }, - }, - }, - { - "name": "VECTOR_CONFIG_YAML", - "value": "/stackable/config/vector.yaml", - }, - { - "name": "VECTOR_FILE_LOG_LEVEL", - "value": "info", - }, - { - "name": "VECTOR_LOG", - "value": "info", - }, - ], - "image": "oci.stackable.tech/sdp/opensearch:3.4.0-stackable0.0.0-dev", - "imagePullPolicy": "Always", - "name": "vector", - "resources": { - "limits": { - "cpu": "500m", - "memory": "128Mi", - }, - "requests": { - "cpu": "250m", - "memory": "128Mi", - }, - }, - "volumeMounts": [ - { - "mountPath": "/stackable/config/vector.yaml", - "name": "config", - "readOnly": true, - "subPath": "vector.yaml", - }, - { - "mountPath": "/stackable/log", - "name": "log", - }, - ], - }, - ], - "initContainers": [ - { - "args": [ - concat!( - "bin/opensearch-keystore create\n", - "for i in keystore-secrets/*; do\n", - " key=$(basename $i)\n", - " bin/opensearch-keystore add-file \"$key\" \"$i\"\n", - "done\n", - "cp --archive config/opensearch.keystore initialized-keystore" - ), - ], - "command": [ - "/bin/bash", - "-x", - "-euo", - "pipefail", - "-c" - ], - "image": "oci.stackable.tech/sdp/opensearch:3.4.0-stackable0.0.0-dev", - "imagePullPolicy": "Always", - "name": "init-keystore", - "resources": {}, - "volumeMounts": [ - { - "mountPath": "/stackable/opensearch/initialized-keystore", - "name": "keystore", - }, - { - "mountPath": "/stackable/opensearch/keystore-secrets/Keystore1", - "name": "keystore-0", - "readOnly": true, - "subPath": "my-keystore-file" - } - ] - } - ], + "containers": expected_containers, + "initContainers": expected_init_containers, "securityContext": { "fsGroup": 1000 }, "serviceAccountName": "my-opensearch-cluster-serviceaccount", "terminationGracePeriodSeconds": 30, - "volumes": [ - { - "configMap": { - "defaultMode": 0o660, - "name": "my-opensearch-cluster-nodes-default" - }, - "name": "config" - }, - { - "configMap": { - "defaultMode": 0o660, - "name": "my-opensearch-cluster-nodes-default" - }, - "name": "log-config" - }, - { - "emptyDir": { - "sizeLimit": "30Mi" - }, - "name": "log" - }, - { - "ephemeral": { - "volumeClaimTemplate": { - "metadata": { - "annotations": { - "secrets.stackable.tech/backend.autotls.cert.lifetime": "1d", - "secrets.stackable.tech/class": "tls", - "secrets.stackable.tech/format": "tls-pem", - "secrets.stackable.tech/scope": "service=my-opensearch-cluster-seed-nodes,listener-volume=listener,pod" - } - }, - "spec": { - "accessModes": [ - "ReadWriteOnce" - ], - "resources": { - "requests": { - "storage": "1" - } - }, - "storageClassName": "secrets.stackable.tech" - } - } - }, - "name": "tls-internal" - }, - { - "ephemeral": { - "volumeClaimTemplate": { - "metadata": { - "annotations": { - "secrets.stackable.tech/backend.autotls.cert.lifetime": "1d", - "secrets.stackable.tech/class": "tls", - "secrets.stackable.tech/format": "tls-pem", - "secrets.stackable.tech/scope": "listener-volume=listener,listener-volume=discovery-service-listener,pod" - } - }, - "spec": { - "accessModes": [ - "ReadWriteOnce" - ], - "resources": { - "requests": { - "storage": "1" - } - }, - "storageClassName": "secrets.stackable.tech" - } - } - }, - "name": "tls-server" - }, - { - "emptyDir": { - "sizeLimit": "1Mi" - }, - "name": "keystore" - }, - { - "name": "keystore-0", - "secret": { - "defaultMode": 0o660, - "items": [ - { - "key": "my-keystore-file", - "path": "my-keystore-file" - } - ], - "secretName": "my-keystore-secret" - } - } - ] + "volumes": expected_volumes, } }, "volumeClaimTemplates": [ @@ -1618,10 +3180,16 @@ mod tests { ); } - #[test] - fn test_build_cluster_manager_labels() { - let cluster_manager_labels = - RoleGroupBuilder::cluster_manager_labels(&validated_cluster(), &context_names()); + #[rstest] + #[case::security_mode_initializing(TestSecurityMode::Initializing)] + #[case::security_mode_managing(TestSecurityMode::Managing)] + #[case::security_mode_participating(TestSecurityMode::Participating)] + #[case::security_mode_disabled(TestSecurityMode::Disabled)] + fn test_build_cluster_manager_labels(#[case] security_mode: TestSecurityMode) { + let cluster_manager_labels = RoleGroupBuilder::cluster_manager_labels( + &validated_cluster(security_mode), + &context_names(), + ); assert_eq!( BTreeMap::from( @@ -1637,14 +3205,25 @@ mod tests { ); } - #[test] - fn test_build_headless_service() { + #[rstest] + #[case::security_mode_initializing(TestSecurityMode::Initializing)] + #[case::security_mode_managing(TestSecurityMode::Managing)] + #[case::security_mode_participating(TestSecurityMode::Participating)] + #[case::security_mode_disabled(TestSecurityMode::Disabled)] + fn test_build_headless_service(#[case] security_mode: TestSecurityMode) { + let cluster = validated_cluster(security_mode); let context_names = context_names(); - let role_group_builder = role_group_builder(&context_names); + let role_group_builder = role_group_builder(&cluster, &context_names); let headless_service = serde_json::to_value(role_group_builder.build_headless_service()) .expect("should be serializable"); + let expected_scheme = if security_mode == TestSecurityMode::Disabled { + json!("http") + } else { + json!("https") + }; + assert_eq!( json!({ "apiVersion": "v1", @@ -1653,7 +3232,7 @@ mod tests { "annotations": { "prometheus.io/path": "/_prometheus/metrics", "prometheus.io/port": "9200", - "prometheus.io/scheme": "https", + "prometheus.io/scheme": expected_scheme, "prometheus.io/scrape": "true" }, "labels": { @@ -1704,10 +3283,15 @@ mod tests { ); } - #[test] - fn test_build_listener() { + #[rstest] + #[case::security_mode_initializing(TestSecurityMode::Initializing)] + #[case::security_mode_managing(TestSecurityMode::Managing)] + #[case::security_mode_participating(TestSecurityMode::Participating)] + #[case::security_mode_disabled(TestSecurityMode::Disabled)] + fn test_build_listener(#[case] security_mode: TestSecurityMode) { + let cluster = validated_cluster(security_mode); let context_names = context_names(); - let role_group_builder = role_group_builder(&context_names); + let role_group_builder = role_group_builder(&cluster, &context_names); let listener = serde_json::to_value(role_group_builder.build_listener()) .expect("should be serializable"); @@ -1759,7 +3343,7 @@ mod tests { #[test] fn test_build_node_role_label() { // Test that the function does not panic on all possible inputs - for node_role in v1alpha1::NodeRole::iter() { + for node_role in ValidatedNodeRole::iter() { RoleGroupBuilder::build_node_role_label(&node_role); } } diff --git a/rust/operator-binary/src/controller/build/scripts/create-admin-certificate.sh b/rust/operator-binary/src/controller/build/scripts/create-admin-certificate.sh new file mode 100644 index 0000000..9de5407 --- /dev/null +++ b/rust/operator-binary/src/controller/build/scripts/create-admin-certificate.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash + +set -e -u -o pipefail + +function log () { + level="$1" + message="$2" + + timestamp="$(date --utc +"%FT%T.%3NZ")" + echo "$timestamp [$level] $message" +} + +function info () { + message="$*" + + log INFO "$message" +} + +function check_pod () { + POD_INDEX="${POD_NAME##*-}" + + if test "$POD_INDEX" = "0" + then + info "This pod is responsible for managing the security" \ + "configuration." + else + MANAGING_POD="${POD_NAME%-*}-0" + info "This pod is not responsible for managing the security" \ + "configuration, as such an admin certificate is not " \ + "required. The security configuration is managed by the " \ + "pod \"$MANAGING_POD\"." + + cp \ + /stackable/tls-server/ca.crt \ + /stackable/tls-server-ca/ca.crt + exit 0 + fi +} + +function create_admin_certificate () { + info "Create admin certificate with \"$ADMIN_DN\"" + + openssl req \ + -x509 \ + -nodes \ + -subj=/"$ADMIN_DN" \ + -out=/stackable/tls-admin-cert/tls.crt \ + -keyout=/stackable/tls-admin-cert/tls.key +} + +function concatenate_certificates () { + info "Add admin certificate to the trusted CAs" + + cat \ + /stackable/tls-server/ca.crt \ + /stackable/tls-admin-cert/tls.crt > \ + /stackable/tls-server-ca/ca.crt +} + +check_pod +create_admin_certificate +concatenate_certificates diff --git a/rust/operator-binary/src/controller/build/scripts/init-keystore.sh b/rust/operator-binary/src/controller/build/scripts/init-keystore.sh new file mode 100644 index 0000000..d4f4ac4 --- /dev/null +++ b/rust/operator-binary/src/controller/build/scripts/init-keystore.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash + +set -e -u -x -o pipefail + +bin/opensearch-keystore create + +for i in keystore-secrets/* +do + key=$(basename "$i") + bin/opensearch-keystore add-file "$key" "$i" +done + +cp --archive config/opensearch.keystore initialized-keystore diff --git a/rust/operator-binary/src/controller/build/scripts/update-security-config.sh b/rust/operator-binary/src/controller/build/scripts/update-security-config.sh new file mode 100644 index 0000000..d84a550 --- /dev/null +++ b/rust/operator-binary/src/controller/build/scripts/update-security-config.sh @@ -0,0 +1,151 @@ +#!/usr/bin/env bash + +set -u -o pipefail + +function log () { + level="$1" + message="$2" + + timestamp="$(date --utc +"%FT%T.%3NZ")" + echo "$timestamp [$level] $message" +} + +function info () { + message="$*" + + log INFO "$message" +} + +function warn () { + message="$1" + + log WARN "$message" +} + +function wait_seconds () { + seconds="$1" + + if test "$seconds" = 0 + then + info "Wait until pod is restarted..." + else + info "Wait for $seconds seconds..." + fi + + if test ! -e /stackable/log/_vector/shutdown + then + mkdir --parents /stackable/log/_vector + inotifywait \ + --quiet --quiet \ + --timeout "$seconds" \ + --event create \ + /stackable/log/_vector + fi + + if test -e /stackable/log/_vector/shutdown + then + info "Shut down" + exit 0 + fi +} + +function check_pod () { + POD_INDEX="${POD_NAME##*-}" + + if test "$POD_INDEX" = "0" + then + info "This pod is responsible for managing the security" \ + "configuration." + else + MANAGING_POD="${POD_NAME%-*}-0" + info "This pod is not responsible for managing the security" \ + "configuration. The security configuration is managed by" \ + "the pod \"$MANAGING_POD\"." + + wait_seconds 0 + fi +} + +function initialize_security_index() { + info "Initialize the security index." + + until plugins/opensearch-security/tools/securityadmin.sh \ + --configdir "$OPENSEARCH_PATH_CONF/opensearch-security" \ + --disable-host-name-verification \ + -cacert "$OPENSEARCH_PATH_CONF/tls/ca.crt" \ + -cert "$OPENSEARCH_PATH_CONF/tls/tls.crt" \ + -key "$OPENSEARCH_PATH_CONF/tls/tls.key" + do + warn "Initializing the security index failed." + wait_seconds 10 + done +} + +function update_config () { + filetype="$1" + filename="$2" + + file="$OPENSEARCH_PATH_CONF/opensearch-security/$filename" + + envvar="MANAGE_${filetype^^}" + if test "${!envvar}" = "true" + then + info "Update managed configuration type \"$filetype\"." + + until plugins/opensearch-security/tools/securityadmin.sh \ + --type "$filetype" \ + --file "$file" \ + --disable-host-name-verification \ + -cacert "$OPENSEARCH_PATH_CONF/tls/ca.crt" \ + -cert "$OPENSEARCH_PATH_CONF/tls/tls.crt" \ + -key "$OPENSEARCH_PATH_CONF/tls/tls.key" + do + warn "Updating \"$filetype\" in the security index failed." + wait_seconds 10 + done + else + info "Skip unmanaged configuration type \"$filetype\"." + fi +} + +function update_security_index() { + info "Check the status of the security index." + + STATUS_CODE=$(curl \ + --insecure \ + --cert "$OPENSEARCH_PATH_CONF/tls/tls.crt" \ + --key "$OPENSEARCH_PATH_CONF/tls/tls.key" \ + --silent \ + --output /dev/null \ + --write-out "%{http_code}" \ + https://localhost:9200/.opendistro_security) + if test "$STATUS_CODE" = "200" + then + info "The security index is already initialized." + + update_config actiongroups action_groups.yml + update_config allowlist allowlist.yml + update_config audit audit.yml + update_config config config.yml + update_config internalusers internal_users.yml + update_config nodesdn nodes_dn.yml + update_config roles roles.yml + update_config rolesmapping roles_mapping.yml + update_config tenants tenants.yml + elif test "$STATUS_CODE" = "404" + then + initialize_security_index + else + warn "Checking the security index failed." + wait_seconds 10 + check_security_index + fi +} + +check_pod + +update_security_index + +info "Wait for security configuration changes..." +# Wait until the pod is restarted due to a change of the Secret. +wait_seconds 0 diff --git a/rust/operator-binary/src/controller/preprocess.rs b/rust/operator-binary/src/controller/preprocess.rs new file mode 100644 index 0000000..4965eb0 --- /dev/null +++ b/rust/operator-binary/src/controller/preprocess.rs @@ -0,0 +1,157 @@ +//! The preprocess step in the OpenSearchCluster controller + +use stackable_operator::{ + commons::resources::{PvcConfigFragment, ResourcesFragment}, + k8s_openapi::apimachinery::pkg::api::resource::Quantity, + role_utils::{CommonConfiguration, RoleGroup}, +}; +use tracing::info; + +use crate::{ + crd::{NodeRoles, v1alpha1}, + framework::role_utils::GenericProductSpecificCommonConfig, +}; + +/// Preprocesses the OpenSearchCluster and adds configurations that the user is allowed to leave +/// out +pub fn preprocess(cluster: v1alpha1::OpenSearchCluster) -> v1alpha1::OpenSearchCluster { + preprocess_security_managing_role_group(cluster) +} + +/// Adds the role group defined in [`v1alpha1::Security::managing_role_group`] if the OpenSearch +/// security plugin is enabled, any security settings are managed by the operator and the defined +/// role group does not exist yet +pub fn preprocess_security_managing_role_group( + mut cluster: v1alpha1::OpenSearchCluster, +) -> v1alpha1::OpenSearchCluster { + let security = &cluster.spec.cluster_config.security; + if security.enabled + && !security.settings.is_only_managed_by_api() + && !cluster + .spec + .nodes + .role_groups + .contains_key(&security.managing_role_group.to_string()) + { + info!( + "The security configuration is managed by the role group \"{role_group}\". \ + This role group was not specified explicitly and will be created.", + role_group = security.managing_role_group + ); + + let role_group = + RoleGroup:: { + config: CommonConfiguration { + config: v1alpha1::OpenSearchConfigFragment { + discovery_service_exposed: Some(false), + node_roles: Some(NodeRoles(vec![v1alpha1::NodeRole::CoordinatingOnly])), + resources: ResourcesFragment { + storage: v1alpha1::StorageConfigFragment { + data: PvcConfigFragment { + capacity: Some(Quantity("100Mi".to_owned())), + ..PvcConfigFragment::default() + }, + }, + ..ResourcesFragment::default() + }, + ..v1alpha1::OpenSearchConfigFragment::default() + }, + ..CommonConfiguration::default() + }, + replicas: Some(1), + }; + + cluster + .spec + .nodes + .role_groups + .insert(security.managing_role_group.to_string(), role_group); + } + + cluster +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use pretty_assertions::assert_eq; + use serde_json::json; + + use crate::controller::preprocess::preprocess; + + #[test] + fn test_preprocess_security_managing_role_group() { + let cluster_spec = json!({ + "apiVersion": "opensearch.stackable.tech/v1alpha1", + "kind": "OpenSearchCluster", + "metadata": { + "name": "opensearch", + "namespace": "default", + "uid": "e6ac237d-a6d4-43a1-8135-f36506110912" + }, + "spec": { + "image": { + "productVersion": "3.4.0" + }, + "clusterConfig": { + "security": { + "managingRoleGroup": "security-manager", + "settings": { + "config": { + "managedBy": "operator", + "content": { + "valueFrom": { + "configMapKeyRef": { + "name": "opensearch-security-config", + "key": "config.yml" + } + } + } + } + } + } + }, + "nodes": { + "roleGroups": { + "default": { + "replicas": 1 + } + } + } + } + }); + + let cluster = serde_json::from_value(cluster_spec).expect("should be deserializable"); + let prepocessed_cluster = preprocess(cluster); + + let expected_role_groups_spec = json!({ + "default": { + "replicas": 1 + }, + "security-manager": { + "config" : { + "discoveryServiceExposed": false, + "nodeRoles": [ + "coordinating_only" + ], + "resources": { + "storage": { + "data": { + "capacity": "100Mi" + } + } + } + }, + "replicas": 1 + } + }); + let expected_role_groups: HashMap<_, _> = + serde_json::from_value(expected_role_groups_spec).expect("should be deserializable"); + + assert_eq!( + expected_role_groups, + prepocessed_cluster.spec.nodes.role_groups + ); + } +} diff --git a/rust/operator-binary/src/controller/validate.rs b/rust/operator-binary/src/controller/validate.rs index d161bfb..62bbaf1 100644 --- a/rust/operator-binary/src/controller/validate.rs +++ b/rust/operator-binary/src/controller/validate.rs @@ -2,7 +2,7 @@ use std::{collections::BTreeMap, str::FromStr}; -use snafu::{OptionExt, ResultExt, Snafu}; +use snafu::{OptionExt, ResultExt, Snafu, ensure}; use stackable_operator::{ crd::listener, kube::ResourceExt, product_logging::spec::Logging, role_utils::RoleGroup, shared::time::Duration, @@ -14,8 +14,11 @@ use super::{ ValidatedLogging, ValidatedOpenSearchConfig, }; use crate::{ - controller::{DereferencedObjects, HTTP_PORT_NAME, ValidatedDiscoveryEndpoint}, - crd::v1alpha1::{self}, + controller::{ + DereferencedObjects, HTTP_PORT_NAME, ValidatedDiscoveryEndpoint, ValidatedNodeRole, + ValidatedNodeRoles, ValidatedSecurity, + }, + crd::{NodeRoles, v1alpha1}, framework::{ builder::pod::container::{EnvVarName, EnvVarSet}, controller_utils::{get_cluster_name, get_namespace, get_uid}, @@ -34,6 +37,21 @@ use crate::{ #[derive(Snafu, Debug, EnumDiscriminants)] #[strum_discriminants(derive(IntoStaticStr))] pub enum Error { + #[snafu(display( + "The role group that is defined to manage the security configuration, is not specified." + ))] + CheckSecurityConfigManagingRoleGroup { + security_config_managing_role_group: RoleGroupName, + }, + + #[snafu(display( + "A TLS server SecretClass must be set if the security configuration is managed by the operator." + ))] + CheckSecurityConfigTlsSettings {}, + + #[snafu(display("node role \"coordinating_only\" not compatible with other node roles"))] + ConflictingNodeRoles {}, + #[snafu(display("failed to get the cluster name"))] GetClusterName { source: crate::framework::controller_utils::Error, @@ -87,6 +105,12 @@ pub enum Error { source: stackable_operator::commons::product_image_selection::Error, }, + #[snafu(display("termination grace period is too long (got {duration}, maximum allowed is {max})", max = Duration::from_secs(i64::MAX as u64)))] + TerminationGracePeriodTooLong { + source: std::num::TryFromIntError, + duration: Duration, + }, + #[snafu(display("failed to validate the logging configuration"))] ValidateLoggingConfig { source: crate::framework::product_logging::framework::Error, @@ -96,12 +120,6 @@ pub enum Error { ValidateOpenSearchConfig { source: stackable_operator::config::fragment::ValidationError, }, - - #[snafu(display("termination grace period is too long (got {duration}, maximum allowed is {max})", max = Duration::from_secs(i64::MAX as u64)))] - TerminationGracePeriodTooLong { - source: std::num::TryFromIntError, - duration: Duration, - }, } type Result = std::result::Result; @@ -146,6 +164,8 @@ pub fn validate( role_group_configs.insert(role_group_name, validated_role_group_config); } + let validated_security = validate_security_config(&cluster.spec)?; + let validated_discovery_endpoint = validate_discovery_endpoint( dereferenced_objects .maybe_discovery_service_listener @@ -160,7 +180,7 @@ pub fn validate( uid, cluster.spec.nodes.role_config.clone(), role_group_configs, - cluster.spec.cluster_config.tls.clone(), + validated_security, cluster.spec.cluster_config.keystore.clone(), validated_discovery_endpoint, )) @@ -194,6 +214,8 @@ fn validate_role_group_config( .vector_aggregator_config_map_name, )?; + let node_roles = validate_node_roles(&merged_role_group.config.config.node_roles)?; + let graceful_shutdown_timeout = merged_role_group.config.config.graceful_shutdown_timeout; let termination_grace_period_seconds = graceful_shutdown_timeout.as_secs().try_into().context( TerminationGracePeriodTooLongSnafu { @@ -206,7 +228,7 @@ fn validate_role_group_config( discovery_service_exposed: merged_role_group.config.config.discovery_service_exposed, listener_class: merged_role_group.config.config.listener_class, logging, - node_roles: merged_role_group.config.config.node_roles, + node_roles, requested_secret_lifetime: merged_role_group.config.config.requested_secret_lifetime, resources: merged_role_group.config.config.resources, termination_grace_period_seconds, @@ -263,6 +285,77 @@ fn validate_logging_configuration( }) } +fn validate_node_roles(node_roles: &NodeRoles) -> Result { + if node_roles.0.is_empty() || node_roles.0 == vec![v1alpha1::NodeRole::CoordinatingOnly] { + Ok(ValidatedNodeRoles::new()) + } else { + let validated_node_roles = node_roles + .0 + .iter() + .map(|node_role| match node_role { + v1alpha1::NodeRole::ClusterManager => Ok(ValidatedNodeRole::ClusterManager), + v1alpha1::NodeRole::CoordinatingOnly => ConflictingNodeRolesSnafu.fail(), + v1alpha1::NodeRole::Data => Ok(ValidatedNodeRole::Data), + v1alpha1::NodeRole::Ingest => Ok(ValidatedNodeRole::Ingest), + v1alpha1::NodeRole::RemoteClusterClient => { + Ok(ValidatedNodeRole::RemoteClusterClient) + } + v1alpha1::NodeRole::Warm => Ok(ValidatedNodeRole::Warm), + v1alpha1::NodeRole::Search => Ok(ValidatedNodeRole::Search), + }) + .collect::>()?; + + Ok(validated_node_roles) + } +} + +fn validate_security_config(spec: &v1alpha1::OpenSearchClusterSpec) -> Result { + let security = if spec.cluster_config.security.enabled { + if spec + .cluster_config + .security + .settings + .is_only_managed_by_api() + { + ValidatedSecurity::ManagedByApi { + settings: spec.cluster_config.security.settings.clone(), + tls_server_secret_class: spec.cluster_config.tls.server_secret_class.clone(), + tls_internal_secret_class: spec.cluster_config.tls.internal_secret_class.clone(), + } + } else { + let managing_role_group = spec.cluster_config.security.managing_role_group.clone(); + + ensure!( + spec.nodes + .role_groups + .contains_key(&managing_role_group.to_string()), + CheckSecurityConfigManagingRoleGroupSnafu { + security_config_managing_role_group: managing_role_group + } + ); + + // The role group requires server TLS to communicate with the cluster. + let tls_server_secret_class = spec + .cluster_config + .tls + .server_secret_class + .clone() + .context(CheckSecurityConfigTlsSettingsSnafu)?; + + ValidatedSecurity::ManagedByOperator { + managing_role_group, + settings: spec.cluster_config.security.settings.clone(), + tls_server_secret_class, + tls_internal_secret_class: spec.cluster_config.tls.internal_secret_class.clone(), + } + } + } else { + ValidatedSecurity::Disabled + }; + + Ok(security) +} + /// Return the validated discovery endpoint if a Listener is given with a status containing the /// endpoint fn validate_discovery_endpoint( @@ -366,7 +459,7 @@ mod tests { built_info, controller::{ ContextNames, DereferencedObjects, ValidatedCluster, ValidatedDiscoveryEndpoint, - ValidatedLogging, ValidatedOpenSearchConfig, + ValidatedLogging, ValidatedNodeRole, ValidatedOpenSearchConfig, ValidatedSecurity, }, crd::{NodeRoles, OpenSearchKeystoreKey, v1alpha1}, framework::{ @@ -378,8 +471,8 @@ mod tests { types::{ common::Port, kubernetes::{ - ConfigMapName, Hostname, ListenerClassName, NamespaceName, SecretClassName, - SecretKey, SecretName, + ConfigMapKey, ConfigMapName, Hostname, ListenerClassName, NamespaceName, + SecretClassName, SecretKey, SecretName, }, operator::{ ClusterName, ControllerName, OperatorName, ProductName, ProductVersion, @@ -501,15 +594,13 @@ mod tests { ConfigMapName::from_str_unsafe("vector-aggregator"), }), }, - node_roles: NodeRoles( - [ - v1alpha1::NodeRole::ClusterManager, - v1alpha1::NodeRole::Ingest, - v1alpha1::NodeRole::Data, - v1alpha1::NodeRole::RemoteClusterClient - ] - .into() - ), + node_roles: [ + ValidatedNodeRole::ClusterManager, + ValidatedNodeRole::Ingest, + ValidatedNodeRole::Data, + ValidatedNodeRole::RemoteClusterClient + ] + .into(), requested_secret_lifetime: Duration::from_str("1d") .expect("should be a valid duration"), resources: Resources { @@ -597,9 +688,24 @@ mod tests { } )] .into(), - v1alpha1::OpenSearchTls { - server_secret_class: Some(SecretClassName::from_str_unsafe("tls")), - internal_secret_class: SecretClassName::from_str_unsafe("tls") + ValidatedSecurity::ManagedByOperator { + managing_role_group: RoleGroupName::from_str_unsafe("default"), + settings: v1alpha1::SecuritySettings { + config: v1alpha1::SecuritySettingsFileType { + managed_by: v1alpha1::SecuritySettingsFileTypeManagedBy::Operator, + content: v1alpha1::SecuritySettingsFileTypeContent::ValueFrom( + v1alpha1::SecuritySettingsFileTypeContentValueFrom::ConfigMapKeyRef( + v1alpha1::ConfigMapKeyRef { + name: ConfigMapName::from_str_unsafe("security-config"), + key: ConfigMapKey::from_str_unsafe("config.yml") + } + ) + ) + }, + ..v1alpha1::SecuritySettings::default() + }, + tls_server_secret_class: SecretClassName::from_str_unsafe("tls"), + tls_internal_secret_class: SecretClassName::from_str_unsafe("tls") }, vec![v1alpha1::OpenSearchKeystore { key: OpenSearchKeystoreKey::from_str_unsafe("Keystore1"), @@ -708,6 +814,30 @@ mod tests { ); } + #[test] + fn test_validate_err_conflicting_node_roles() { + test_validate_err( + |cluster, _| { + cluster + .spec + .nodes + .role_groups + .get_mut("default") + .expect("should be defined in the test cluster spec") + .config + .config + .node_roles = Some(NodeRoles(vec![ + v1alpha1::NodeRole::ClusterManager, + v1alpha1::NodeRole::CoordinatingOnly, + v1alpha1::NodeRole::Ingest, + v1alpha1::NodeRole::Data, + v1alpha1::NodeRole::RemoteClusterClient, + ])); + }, + ErrorDiscriminants::ConflictingNodeRoles, + ); + } + #[test] fn test_validate_err_termination_grace_period_too_long() { test_validate_err( @@ -800,6 +930,34 @@ mod tests { ); } + #[test] + fn test_validate_err_check_security_config_managing_role_group() { + test_validate_err( + |cluster, _| { + cluster.spec.cluster_config.security.managing_role_group = + RoleGroupName::from_str_unsafe("non-existent"); + }, + ErrorDiscriminants::CheckSecurityConfigManagingRoleGroup, + ); + } + + #[test] + fn test_validate_err_check_security_config_tls_settings() { + test_validate_err( + |cluster, _| { + cluster + .spec + .cluster_config + .security + .settings + .config + .managed_by = v1alpha1::SecuritySettingsFileTypeManagedBy::Operator; + cluster.spec.cluster_config.tls.server_secret_class = None; + }, + ErrorDiscriminants::CheckSecurityConfigTlsSettings, + ); + } + fn test_validate_err( change_test_objects: fn(&mut v1alpha1::OpenSearchCluster, &mut DereferencedObjects) -> (), expected_err: ErrorDiscriminants, @@ -843,6 +1001,24 @@ mod tests { key: SecretKey::from_str_unsafe("my-keystore-file"), }, }], + security: v1alpha1::Security { + enabled: true, + managing_role_group: RoleGroupName::from_str_unsafe("default"), + settings: v1alpha1::SecuritySettings { + config: v1alpha1::SecuritySettingsFileType { + managed_by: v1alpha1::SecuritySettingsFileTypeManagedBy::Operator, + content: v1alpha1::SecuritySettingsFileTypeContent::ValueFrom( + v1alpha1::SecuritySettingsFileTypeContentValueFrom::ConfigMapKeyRef( + v1alpha1::ConfigMapKeyRef { + name: ConfigMapName::from_str_unsafe("security-config"), + key: ConfigMapKey::from_str_unsafe("config.yml") + } + ) + ), + }, + ..v1alpha1::SecuritySettings::default() + }, + }, vector_aggregator_config_map_name: Some(ConfigMapName::from_str_unsafe( "vector-aggregator", )), diff --git a/rust/operator-binary/src/crd/mod.rs b/rust/operator-binary/src/crd/mod.rs index 1ddfa03..dde1eb6 100644 --- a/rust/operator-binary/src/crd/mod.rs +++ b/rust/operator-binary/src/crd/mod.rs @@ -1,6 +1,7 @@ -use std::{slice, str::FromStr}; +use std::{array, str::FromStr}; use serde::{Deserialize, Serialize}; +use serde_json::json; use stackable_operator::{ commons::{ affinity::{StackableAffinity, StackableAffinityFragment, affinity_between_role_pods}, @@ -23,6 +24,7 @@ use stackable_operator::{ schemars::{self, JsonSchema}, shared::time::Duration, status::condition::{ClusterCondition, HasStatusCondition}, + utils::crds::raw_object_schema, versioned::versioned, }; use strum::{Display, EnumIter}; @@ -34,10 +36,10 @@ use crate::{ role_utils::GenericProductSpecificCommonConfig, types::{ kubernetes::{ - ConfigMapName, ContainerName, ListenerClassName, SecretClassName, SecretKey, - SecretName, + ConfigMapKey, ConfigMapName, ContainerName, ListenerClassName, SecretClassName, + SecretKey, SecretName, }, - operator::{ClusterName, ProductName, RoleName}, + operator::{ClusterName, ProductName, RoleGroupName, RoleName}, }, }, }; @@ -57,6 +59,7 @@ constant!(TLS_DEFAULT_SECRET_CLASS: SecretClassName = "tls"); ) )] pub mod versioned { + /// An OpenSearch cluster stacklet. This resource is managed by the Stackable operator for /// OpenSearch. Find more information on how to use it and the resources that the operator /// generates in the [operator documentation](DOCS_BASE_URL_PLACEHOLDER/opensearch/). @@ -101,7 +104,13 @@ pub mod versioned { #[serde(default)] pub keystore: Vec, + /// Configuration of the OpenSearch security plugin + #[serde(default)] + pub security: Security, + /// TLS configuration options for the server (REST API) and internal communication (transport). + /// + /// This configuration is only effective if the OpenSearch security plugin is not disabled. #[serde(default)] pub tls: OpenSearchTls, @@ -123,10 +132,172 @@ pub mod versioned { pub secret_key_ref: SecretKeyRef, } + /// Configuration of the OpenSearch security plugin + #[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] + #[serde(rename_all = "camelCase")] + pub struct Security { + /// Whether to enable the OpenSearch security plugin + /// + /// Disabling the security plugin also disables TLS and exposes the security index if it + /// exists. + #[serde(default = "security_config_enabled_default")] + pub enabled: bool, + + /// The role group that updates the security index if any setting is managed by the operator. + #[serde(default = "security_config_managing_role_group_default")] + pub managing_role_group: RoleGroupName, + + /// Settings for the OpenSearch security plugin + #[serde(default)] + pub settings: SecuritySettings, + } + + /// Configuration files of the OpenSearch security plugin + #[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] + #[serde(rename_all = "camelCase")] + pub struct SecuritySettings { + /// User-defined action groups + /// + /// see + #[serde(default = "security_settings_file_type_default_actiongroups")] + pub action_groups: SecuritySettingsFileType, + + /// List of allowed HTTP endpoints + /// + /// see + #[serde(default = "security_settings_file_type_default_allowlist")] + pub allow_list: SecuritySettingsFileType, + + /// Settings for audit logging + /// + /// see + /// + #[serde(default = "security_settings_file_type_default_audit")] + pub audit: SecuritySettingsFileType, + + /// Configuration of the security backend + /// + /// see + #[serde(default = "security_settings_file_type_default_config")] + pub config: SecuritySettingsFileType, + + /// The internal user database + /// + /// see + #[serde(default = "security_settings_file_type_default_internalusers")] + pub internal_users: SecuritySettingsFileType, + + /// Distinguished names (DNs) of nodes to allow communication between nodes and clusters + /// + /// see + #[serde(default = "security_settings_file_type_default_nodesdn")] + pub nodes_dn: SecuritySettingsFileType, + + /// Definition of roles in the security plugin + /// + /// see + #[serde(default = "security_settings_file_type_default_roles")] + pub roles: SecuritySettingsFileType, + + /// Role mappings to users or backend roles + /// + /// see + #[serde(default = "security_settings_file_type_default_rolesmapping")] + pub roles_mapping: SecuritySettingsFileType, + + /// OpenSearch Dashboards tenants + /// + /// see + #[serde(default = "security_settings_file_type_default_tenants")] + pub tenants: SecuritySettingsFileType, + } + + /// Specific configuration file of the OpenSearch security plugin + #[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] + #[serde(rename_all = "camelCase")] + pub struct SecuritySettingsFileType { + /// Whether this configuration should only be applied initially and afterwards be managed + /// via the "API", or managed all the time by the "operator". + /// + /// If this configuration is changed later from "API" to "operator", then the changes made + /// via the API are overridden. + // There is no default, so that the user is aware of this choice. + pub managed_by: SecuritySettingsFileTypeManagedBy, + + /// The content of the security configuration file + pub content: SecuritySettingsFileTypeContent, + } + + /// Responsibility for initializing and updating the security configuration + #[derive( + Clone, + Debug, + Deserialize, + Display, + EnumIter, + Eq, + JsonSchema, + Ord, + PartialEq, + PartialOrd, + Serialize, + )] + pub enum SecuritySettingsFileTypeManagedBy { + /// Only initially applied by the operator, but afterwards managed via the API. + #[serde(rename = "API")] + Api, + + /// Managed by the operator; Changes made via the API will be eventually overridden. + #[serde(rename = "operator")] + Operator, + } + + /// Content of the security configuration file + #[derive(Clone, Debug, Deserialize, Display, Eq, JsonSchema, PartialEq, Serialize)] + #[serde(rename_all = "camelCase")] + pub enum SecuritySettingsFileTypeContent { + /// Security configuration file content defined inline + Value(SecuritySettingsFileTypeContentValue), + + /// Security configuration file content ingested from a ConfigMap or Secret + ValueFrom(SecuritySettingsFileTypeContentValueFrom), + } + + /// Security configuration file content defined inline + #[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] + pub struct SecuritySettingsFileTypeContentValue { + #[serde(flatten)] + #[schemars(schema_with = "raw_object_schema")] + value: serde_json::Value, + } + + /// Security configuration file content ingested from a ConfigMap or Secret + #[derive(Clone, Debug, Deserialize, Display, Eq, JsonSchema, PartialEq, Serialize)] + #[serde(rename_all = "camelCase")] + pub enum SecuritySettingsFileTypeContentValueFrom { + /// Reference to a key in a ConfigMap + ConfigMapKeyRef(ConfigMapKeyRef), + + /// Reference to a key in a Secret + SecretKeyRef(SecretKeyRef), + } + + /// Reference to a key in a ConfigMap + #[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] + pub struct ConfigMapKeyRef { + /// Name of the ConfigMap + pub name: ConfigMapName, + + /// Key in the ConfigMap that contains the value + pub key: ConfigMapKey, + } + + /// Reference to a key in a Secret #[derive(Clone, Debug, Deserialize, Eq, JsonSchema, PartialEq, Serialize)] pub struct SecretKeyRef { /// Name of the Secret pub name: SecretName, + /// Key in the Secret that contains the value pub key: SecretKey, } @@ -266,6 +437,12 @@ pub mod versioned { #[serde(rename = "vector")] Vector, + #[serde(rename = "create-admin-certificate")] + CreateAdminCertificate, + + #[serde(rename = "update-security-config")] + UpdateSecurityConfig, + #[serde(rename = "init-keystore")] InitKeystore, } @@ -391,6 +568,230 @@ impl v1alpha1::OpenSearchConfig { } } +impl Default for v1alpha1::Security { + fn default() -> Self { + Self { + enabled: security_config_enabled_default(), + managing_role_group: security_config_managing_role_group_default(), + settings: v1alpha1::SecuritySettings::default(), + } + } +} + +impl v1alpha1::SecuritySettings { + pub fn is_only_managed_by_api(&self) -> bool { + self.into_iter() + .all(|config| *config.managed_by == v1alpha1::SecuritySettingsFileTypeManagedBy::Api) + } +} + +impl Default for v1alpha1::SecuritySettings { + fn default() -> Self { + Self { + action_groups: security_settings_file_type_default_actiongroups(), + allow_list: security_settings_file_type_default_allowlist(), + audit: security_settings_file_type_default_audit(), + config: security_settings_file_type_default_config(), + internal_users: security_settings_file_type_default_internalusers(), + nodes_dn: security_settings_file_type_default_nodesdn(), + roles_mapping: security_settings_file_type_default_rolesmapping(), + roles: security_settings_file_type_default_roles(), + tenants: security_settings_file_type_default_tenants(), + } + } +} + +/// [`v1alpha1::SecuritySettingsFileType`] extended with ID and filename +pub struct ExtendedSecuritySettingsFileType<'a> { + /// The ID of the file type as set in the `_meta.type` field; can be used to construct + /// volume names + pub id: &'static str, + + /// The file name as expected by the OpenSearch security plugin + pub filename: &'static str, + + pub managed_by: &'a v1alpha1::SecuritySettingsFileTypeManagedBy, + + pub content: &'a v1alpha1::SecuritySettingsFileTypeContent, +} + +impl<'a> IntoIterator for &'a v1alpha1::SecuritySettings { + type IntoIter = array::IntoIter; + type Item = ExtendedSecuritySettingsFileType<'a>; + + fn into_iter(self) -> Self::IntoIter { + IntoIterator::into_iter([ + ExtendedSecuritySettingsFileType { + id: "actiongroups", + filename: "action_groups.yml", + managed_by: &self.action_groups.managed_by, + content: &self.action_groups.content, + }, + ExtendedSecuritySettingsFileType { + id: "allowlist", + filename: "allowlist.yml", + managed_by: &self.allow_list.managed_by, + content: &self.allow_list.content, + }, + ExtendedSecuritySettingsFileType { + id: "audit", + filename: "audit.yml", + managed_by: &self.audit.managed_by, + content: &self.audit.content, + }, + ExtendedSecuritySettingsFileType { + id: "config", + filename: "config.yml", + managed_by: &self.config.managed_by, + content: &self.config.content, + }, + ExtendedSecuritySettingsFileType { + id: "internalusers", + filename: "internal_users.yml", + managed_by: &self.internal_users.managed_by, + content: &self.internal_users.content, + }, + ExtendedSecuritySettingsFileType { + id: "nodesdn", + filename: "nodes_dn.yml", + managed_by: &self.nodes_dn.managed_by, + content: &self.nodes_dn.content, + }, + ExtendedSecuritySettingsFileType { + id: "roles", + filename: "roles.yml", + managed_by: &self.roles.managed_by, + content: &self.roles.content, + }, + ExtendedSecuritySettingsFileType { + id: "rolesmapping", + filename: "roles_mapping.yml", + managed_by: &self.roles_mapping.managed_by, + content: &self.roles_mapping.content, + }, + ExtendedSecuritySettingsFileType { + id: "tenants", + filename: "tenants.yml", + managed_by: &self.tenants.managed_by, + content: &self.tenants.content, + }, + ]) + } +} + +fn security_config_enabled_default() -> bool { + true +} + +fn security_config_managing_role_group_default() -> RoleGroupName { + RoleGroupName::from_str("security-config").expect("should be a valid role group name") +} + +fn security_settings_file_type_default_actiongroups() -> v1alpha1::SecuritySettingsFileType { + security_settings_file_type_default(json!({ + "_meta": { + "type": "actiongroups", + "config_version": 2 + } + })) +} + +fn security_settings_file_type_default_allowlist() -> v1alpha1::SecuritySettingsFileType { + security_settings_file_type_default(json!({ + "_meta": { + "type": "allowlist", + "config_version": 2 + }, + "config": { + "enabled": false + } + })) +} + +fn security_settings_file_type_default_audit() -> v1alpha1::SecuritySettingsFileType { + security_settings_file_type_default(json!({ + "_meta": { + "type": "audit", + "config_version": 2 + }, + "config": { + "enabled": false + } + })) +} + +fn security_settings_file_type_default_config() -> v1alpha1::SecuritySettingsFileType { + security_settings_file_type_default(json!({ + "_meta": { + "type": "config", + "config_version": 2 + }, + "config": { + "dynamic": { + "http": {}, + "authc": {}, + "authz": {} + } + } + })) +} + +fn security_settings_file_type_default_internalusers() -> v1alpha1::SecuritySettingsFileType { + security_settings_file_type_default(json!({ + "_meta": { + "type": "internalusers", + "config_version": 2 + } + })) +} + +fn security_settings_file_type_default_nodesdn() -> v1alpha1::SecuritySettingsFileType { + security_settings_file_type_default(json!({ + "_meta": { + "type": "nodesdn", + "config_version": 2 + } + })) +} + +fn security_settings_file_type_default_roles() -> v1alpha1::SecuritySettingsFileType { + security_settings_file_type_default(json!({ + "_meta": { + "type": "roles", + "config_version": 2 + } + })) +} + +fn security_settings_file_type_default_rolesmapping() -> v1alpha1::SecuritySettingsFileType { + security_settings_file_type_default(json!({ + "_meta": { + "type": "rolesmapping", + "config_version": 2 + } + })) +} + +fn security_settings_file_type_default_tenants() -> v1alpha1::SecuritySettingsFileType { + security_settings_file_type_default(json!({ + "_meta": { + "type": "tenants", + "config_version": 2 + } + })) +} + +fn security_settings_file_type_default( + value: serde_json::Value, +) -> v1alpha1::SecuritySettingsFileType { + v1alpha1::SecuritySettingsFileType { + managed_by: v1alpha1::SecuritySettingsFileTypeManagedBy::Api, + content: v1alpha1::SecuritySettingsFileTypeContent::Value( + v1alpha1::SecuritySettingsFileTypeContentValue { value }, + ), + } +} + impl Default for v1alpha1::OpenSearchTls { fn default() -> Self { v1alpha1::OpenSearchTls { @@ -424,16 +825,6 @@ fn discovery_service_listener_class_default() -> ListenerClassName { #[derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq, Serialize)] pub struct NodeRoles(pub Vec); -impl NodeRoles { - pub fn contains(&self, node_role: &v1alpha1::NodeRole) -> bool { - self.0.contains(node_role) - } - - pub fn iter(&self) -> slice::Iter<'_, v1alpha1::NodeRole> { - self.0.iter() - } -} - impl Atomic for NodeRoles {} impl v1alpha1::Container { @@ -445,6 +836,8 @@ impl v1alpha1::Container { ContainerName::from_str(match self { v1alpha1::Container::OpenSearch => "opensearch", v1alpha1::Container::Vector => "vector", + v1alpha1::Container::CreateAdminCertificate => "create-admin-certificate", + v1alpha1::Container::UpdateSecurityConfig => "update-security-config", v1alpha1::Container::InitKeystore => "init-keystore", }) .expect("should be a valid container name") @@ -464,7 +857,7 @@ attributed_string_type! { mod tests { use strum::IntoEnumIterator; - use crate::crd::v1alpha1; + use crate::crd::{security_config_managing_role_group_default, v1alpha1}; #[test] fn test_node_role() { @@ -494,4 +887,10 @@ mod tests { container.to_container_name(); } } + + #[test] + fn test_security_config_managing_role_group_default() { + // Test that the function does not panic + security_config_managing_role_group_default(); + } } diff --git a/rust/operator-binary/src/main.rs b/rust/operator-binary/src/main.rs index 43f6299..2105788 100644 --- a/rust/operator-binary/src/main.rs +++ b/rust/operator-binary/src/main.rs @@ -1,3 +1,6 @@ +// Increase the recursion limit because some unit tests use large JSON structures. +#![recursion_limit = "256"] + use std::{str::FromStr, sync::Arc}; use clap::Parser as _; diff --git a/tests/templates/kuttl/backup-restore/20-create-opensearch-1-admin-certificate.yaml b/tests/templates/kuttl/backup-restore/20-create-opensearch-1-admin-certificate.yaml index 9e5592f..3cdbf52 100644 --- a/tests/templates/kuttl/backup-restore/20-create-opensearch-1-admin-certificate.yaml +++ b/tests/templates/kuttl/backup-restore/20-create-opensearch-1-admin-certificate.yaml @@ -36,6 +36,7 @@ spec: securityContext: fsGroup: 1000 restartPolicy: OnFailure + backoffLimit: 10 --- apiVersion: v1 kind: ConfigMap diff --git a/tests/templates/kuttl/backup-restore/21-install-opensearch-1.yaml.j2 b/tests/templates/kuttl/backup-restore/21-install-opensearch-1.yaml.j2 index f72b709..7864cea 100644 --- a/tests/templates/kuttl/backup-restore/21-install-opensearch-1.yaml.j2 +++ b/tests/templates/kuttl/backup-restore/21-install-opensearch-1.yaml.j2 @@ -12,14 +12,55 @@ spec: pullPolicy: IfNotPresent clusterConfig: keystore: - - key: s3.client.default.access_key - secretKeyRef: - name: s3-credentials - key: ACCESS_KEY - - key: s3.client.default.secret_key - secretKeyRef: - name: s3-credentials - key: SECRET_KEY + - key: s3.client.default.access_key + secretKeyRef: + name: s3-credentials + key: ACCESS_KEY + - key: s3.client.default.secret_key + secretKeyRef: + name: s3-credentials + key: SECRET_KEY + security: + settings: + config: + managedBy: API + content: + value: + _meta: + type: config + config_version: 2 + config: + dynamic: + authc: + basic_internal_auth_domain: + description: Authenticate via HTTP Basic against internal users database + http_enabled: true + transport_enabled: true + order: 1 + http_authenticator: + type: basic + challenge: true + authentication_backend: + type: intern + authz: {} + internalUsers: + managedBy: API + content: + valueFrom: + secretKeyRef: + name: opensearch-1-security-config + key: internal_users.yml + rolesMapping: + managedBy: API + content: + value: + _meta: + type: rolesmapping + config_version: 2 + all_access: + reserved: false + backend_roles: + - admin {% if lookup('env', 'VECTOR_AGGREGATOR') %} vectorAggregatorConfigMapName: vector-aggregator-discovery {% endif %} @@ -41,7 +82,6 @@ spec: # `cluster.routing.allocation.disk.watermark.high` is reached then the security index could # not be created even if enough disk space would be available. cluster.routing.allocation.disk.threshold_enabled: "false" - plugins.security.allow_default_init_securityindex: "true" plugins.security.authcz.admin_dn: CN=opensearch-1-admin-certificate plugins.security.restapi.roles_enabled: all_access plugins.security.ssl.http.pemtrustedcas_filepath: /stackable/opensearch/config/tls/concatenated/ca.crt @@ -110,9 +150,6 @@ spec: containers: - name: opensearch volumeMounts: - - name: security-config - mountPath: /stackable/opensearch/config/opensearch-security - readOnly: true {% if test_scenario['values']['s3-use-tls'] == 'true' %} - name: system-trust-store mountPath: /etc/pki/java/cacerts @@ -127,10 +164,6 @@ spec: secret: secretName: opensearch-1-admin-certificate defaultMode: 0o660 - - name: security-config - secret: - secretName: opensearch-1-security-config - defaultMode: 0o660 {% if test_scenario['values']['s3-use-tls'] == 'true' %} - name: s3-ca-crt secret: @@ -149,51 +182,8 @@ kind: Secret metadata: name: opensearch-1-security-config stringData: - action_groups.yml: | - --- - _meta: - type: actiongroups - config_version: 2 - allowlist.yml: | - --- - _meta: - type: allowlist - config_version: 2 - - config: - enabled: false - audit.yml: | - --- - _meta: - type: audit - config_version: 2 - - config: - enabled: false - config.yml: | - --- - _meta: - type: config - config_version: 2 - - config: - dynamic: - authc: - basic_internal_auth_domain: - description: Authenticate via HTTP Basic against internal users database - http_enabled: true - transport_enabled: true - order: 1 - http_authenticator: - type: basic - challenge: true - authentication_backend: - type: intern - authz: {} internal_users.yml: | --- - # The hash value is a bcrypt hash and can be generated with plugin/tools/hash.sh - _meta: type: internalusers config_version: 2 @@ -204,38 +194,3 @@ stringData: backend_roles: - admin description: OpenSearch admin user - - kibanaserver: - hash: $2y$10$vPgQ/6ilKDM5utawBqxoR.7euhVQ0qeGl8mPTeKhmFT475WUDrfQS - reserved: true - description: OpenSearch Dashboards user - nodes_dn.yml: | - --- - _meta: - type: nodesdn - config_version: 2 - roles.yml: | - --- - _meta: - type: roles - config_version: 2 - roles_mapping.yml: | - --- - _meta: - type: rolesmapping - config_version: 2 - - all_access: - reserved: false - backend_roles: - - admin - - kibana_server: - reserved: true - users: - - kibanaserver - tenants.yml: | - --- - _meta: - type: tenants - config_version: 2 diff --git a/tests/templates/kuttl/backup-restore/22-create-testuser.yaml b/tests/templates/kuttl/backup-restore/22-create-testuser.yaml index 6d171a0..8284902 100644 --- a/tests/templates/kuttl/backup-restore/22-create-testuser.yaml +++ b/tests/templates/kuttl/backup-restore/22-create-testuser.yaml @@ -16,7 +16,7 @@ spec: - -c args: - | - pip install opensearch-py==3.0.0 + pip install opensearch-py==3.1.0 python scripts/create-testuser.py env: # required for pip install @@ -54,6 +54,7 @@ spec: securityContext: fsGroup: 1000 restartPolicy: OnFailure + backoffLimit: 10 --- apiVersion: v1 kind: ConfigMap diff --git a/tests/templates/kuttl/backup-restore/23-create-data.yaml b/tests/templates/kuttl/backup-restore/23-create-data.yaml index 24b9ebe..33f4500 100644 --- a/tests/templates/kuttl/backup-restore/23-create-data.yaml +++ b/tests/templates/kuttl/backup-restore/23-create-data.yaml @@ -16,7 +16,7 @@ spec: - -c args: - | - pip install opensearch-py==3.0.0 + pip install opensearch-py==3.1.0 python scripts/create-data.py env: # required for pip install @@ -54,6 +54,7 @@ spec: securityContext: fsGroup: 1000 restartPolicy: OnFailure + backoffLimit: 10 --- apiVersion: v1 kind: ConfigMap diff --git a/tests/templates/kuttl/backup-restore/30-create-snapshot.yaml b/tests/templates/kuttl/backup-restore/30-create-snapshot.yaml index ff2c654..b38c851 100644 --- a/tests/templates/kuttl/backup-restore/30-create-snapshot.yaml +++ b/tests/templates/kuttl/backup-restore/30-create-snapshot.yaml @@ -16,7 +16,7 @@ spec: - -c args: - | - pip install opensearch-py==3.0.0 + pip install opensearch-py==3.1.0 python scripts/create-snapshot.py env: # required for pip install @@ -54,6 +54,7 @@ spec: securityContext: fsGroup: 1000 restartPolicy: OnFailure + backoffLimit: 10 --- apiVersion: v1 kind: ConfigMap diff --git a/tests/templates/kuttl/backup-restore/31-backup-security-indices.yaml.j2 b/tests/templates/kuttl/backup-restore/31-backup-security-indices.yaml.j2 index 8da84ca..a4f3817 100644 --- a/tests/templates/kuttl/backup-restore/31-backup-security-indices.yaml.j2 +++ b/tests/templates/kuttl/backup-restore/31-backup-security-indices.yaml.j2 @@ -116,6 +116,7 @@ spec: securityContext: fsGroup: 1000 restartPolicy: OnFailure + backoffLimit: 10 --- apiVersion: v1 kind: ConfigMap diff --git a/tests/templates/kuttl/backup-restore/50-create-opensearch-2-admin-certificate.yaml b/tests/templates/kuttl/backup-restore/50-create-opensearch-2-admin-certificate.yaml index d66ef1b..102edab 100644 --- a/tests/templates/kuttl/backup-restore/50-create-opensearch-2-admin-certificate.yaml +++ b/tests/templates/kuttl/backup-restore/50-create-opensearch-2-admin-certificate.yaml @@ -36,6 +36,7 @@ spec: securityContext: fsGroup: 1000 restartPolicy: OnFailure + backoffLimit: 10 --- apiVersion: v1 kind: ConfigMap diff --git a/tests/templates/kuttl/backup-restore/51-install-opensearch-2.yaml.j2 b/tests/templates/kuttl/backup-restore/51-install-opensearch-2.yaml.j2 index c861b32..e91ad3b 100644 --- a/tests/templates/kuttl/backup-restore/51-install-opensearch-2.yaml.j2 +++ b/tests/templates/kuttl/backup-restore/51-install-opensearch-2.yaml.j2 @@ -12,14 +12,55 @@ spec: pullPolicy: IfNotPresent clusterConfig: keystore: - - key: s3.client.default.access_key - secretKeyRef: - name: s3-credentials - key: ACCESS_KEY - - key: s3.client.default.secret_key - secretKeyRef: - name: s3-credentials - key: SECRET_KEY + - key: s3.client.default.access_key + secretKeyRef: + name: s3-credentials + key: ACCESS_KEY + - key: s3.client.default.secret_key + secretKeyRef: + name: s3-credentials + key: SECRET_KEY + security: + settings: + config: + managedBy: API + content: + value: + _meta: + type: config + config_version: 2 + config: + dynamic: + authc: + basic_internal_auth_domain: + description: Authenticate via HTTP Basic against internal users database + http_enabled: true + transport_enabled: true + order: 1 + http_authenticator: + type: basic + challenge: true + authentication_backend: + type: intern + authz: {} + internalUsers: + managedBy: API + content: + valueFrom: + secretKeyRef: + name: opensearch-2-security-config + key: internal_users.yml + rolesMapping: + managedBy: API + content: + value: + _meta: + type: rolesmapping + config_version: 2 + all_access: + reserved: false + backend_roles: + - admin {% if lookup('env', 'VECTOR_AGGREGATOR') %} vectorAggregatorConfigMapName: vector-aggregator-discovery {% endif %} @@ -41,7 +82,6 @@ spec: # `cluster.routing.allocation.disk.watermark.high` is reached then the security index could # not be created even if enough disk space would be available. cluster.routing.allocation.disk.threshold_enabled: "false" - plugins.security.allow_default_init_securityindex: "true" plugins.security.authcz.admin_dn: CN=opensearch-2-admin-certificate plugins.security.restapi.roles_enabled: all_access plugins.security.ssl.http.pemtrustedcas_filepath: /stackable/opensearch/config/tls/concatenated/ca.crt @@ -110,9 +150,6 @@ spec: containers: - name: opensearch volumeMounts: - - name: security-config - mountPath: /stackable/opensearch/config/opensearch-security - readOnly: true {% if test_scenario['values']['s3-use-tls'] == 'true' %} - name: system-trust-store mountPath: /etc/pki/java/cacerts @@ -127,10 +164,6 @@ spec: secret: secretName: opensearch-2-admin-certificate defaultMode: 0o660 - - name: security-config - secret: - secretName: opensearch-2-security-config - defaultMode: 0o660 {% if test_scenario['values']['s3-use-tls'] == 'true' %} - name: s3-ca-crt secret: @@ -149,51 +182,8 @@ kind: Secret metadata: name: opensearch-2-security-config stringData: - action_groups.yml: | - --- - _meta: - type: actiongroups - config_version: 2 - allowlist.yml: | - --- - _meta: - type: allowlist - config_version: 2 - - config: - enabled: false - audit.yml: | - --- - _meta: - type: audit - config_version: 2 - - config: - enabled: false - config.yml: | - --- - _meta: - type: config - config_version: 2 - - config: - dynamic: - authc: - basic_internal_auth_domain: - description: Authenticate via HTTP Basic against internal users database - http_enabled: true - transport_enabled: true - order: 1 - http_authenticator: - type: basic - challenge: true - authentication_backend: - type: intern - authz: {} internal_users.yml: | --- - # The hash value is a bcrypt hash and can be generated with plugin/tools/hash.sh - _meta: type: internalusers config_version: 2 @@ -204,38 +194,3 @@ stringData: backend_roles: - admin description: OpenSearch admin user - - kibanaserver: - hash: $2y$10$vPgQ/6ilKDM5utawBqxoR.7euhVQ0qeGl8mPTeKhmFT475WUDrfQS - reserved: true - description: OpenSearch Dashboards user - nodes_dn.yml: | - --- - _meta: - type: nodesdn - config_version: 2 - roles.yml: | - --- - _meta: - type: roles - config_version: 2 - roles_mapping.yml: | - --- - _meta: - type: rolesmapping - config_version: 2 - - all_access: - reserved: false - backend_roles: - - admin - - kibana_server: - reserved: true - users: - - kibanaserver - tenants.yml: | - --- - _meta: - type: tenants - config_version: 2 diff --git a/tests/templates/kuttl/backup-restore/60-restore-security-indices.yaml.j2 b/tests/templates/kuttl/backup-restore/60-restore-security-indices.yaml.j2 index d2c41fd..be6d766 100644 --- a/tests/templates/kuttl/backup-restore/60-restore-security-indices.yaml.j2 +++ b/tests/templates/kuttl/backup-restore/60-restore-security-indices.yaml.j2 @@ -116,6 +116,7 @@ spec: securityContext: fsGroup: 1000 restartPolicy: OnFailure + backoffLimit: 10 --- apiVersion: v1 kind: ConfigMap diff --git a/tests/templates/kuttl/backup-restore/61-restore-snapshot.yaml b/tests/templates/kuttl/backup-restore/61-restore-snapshot.yaml index 870792a..d436b66 100644 --- a/tests/templates/kuttl/backup-restore/61-restore-snapshot.yaml +++ b/tests/templates/kuttl/backup-restore/61-restore-snapshot.yaml @@ -16,7 +16,7 @@ spec: - -c args: - | - pip install opensearch-py==3.0.0 + pip install opensearch-py==3.1.0 python scripts/restore-snapshot.py env: # required for pip install @@ -54,6 +54,7 @@ spec: securityContext: fsGroup: 1000 restartPolicy: OnFailure + backoffLimit: 10 --- apiVersion: v1 kind: ConfigMap diff --git a/tests/templates/kuttl/backup-restore/70-test-opensearch-2.yaml b/tests/templates/kuttl/backup-restore/70-test-opensearch-2.yaml index 0414a6f..37a3f02 100644 --- a/tests/templates/kuttl/backup-restore/70-test-opensearch-2.yaml +++ b/tests/templates/kuttl/backup-restore/70-test-opensearch-2.yaml @@ -16,7 +16,7 @@ spec: - -c args: - | - pip install opensearch-py==3.0.0 + pip install opensearch-py==3.1.0 python scripts/test-opensearch-2.py env: # required for pip install @@ -54,6 +54,7 @@ spec: securityContext: fsGroup: 1000 restartPolicy: OnFailure + backoffLimit: 10 --- apiVersion: v1 kind: ConfigMap diff --git a/tests/templates/kuttl/external-access/10-listener-classes.yaml b/tests/templates/kuttl/external-access/10-listener-classes.yaml index 893032c..b1096dd 100644 --- a/tests/templates/kuttl/external-access/10-listener-classes.yaml +++ b/tests/templates/kuttl/external-access/10-listener-classes.yaml @@ -2,5 +2,6 @@ apiVersion: kuttl.dev/v1beta1 kind: TestStep commands: - - script: | - envsubst < listener-classes.yaml | kubectl apply -n $NAMESPACE -f - + - script: > + envsubst '$NAMESPACE' < 10_listener-classes.yaml | + kubectl apply -n $NAMESPACE -f - diff --git a/tests/templates/kuttl/external-access/listener-classes.yaml b/tests/templates/kuttl/external-access/10_listener-classes.yaml similarity index 100% rename from tests/templates/kuttl/external-access/listener-classes.yaml rename to tests/templates/kuttl/external-access/10_listener-classes.yaml diff --git a/tests/templates/kuttl/external-access/20-install-opensearch.yaml b/tests/templates/kuttl/external-access/20-install-opensearch.yaml index 78a7a56..cd9666a 100644 --- a/tests/templates/kuttl/external-access/20-install-opensearch.yaml +++ b/tests/templates/kuttl/external-access/20-install-opensearch.yaml @@ -4,5 +4,5 @@ kind: TestStep timeout: 600 commands: - script: > - envsubst < opensearch.yaml | + envsubst '$NAMESPACE' < 20_opensearch.yaml | kubectl apply -n $NAMESPACE -f - diff --git a/tests/templates/kuttl/external-access/20_opensearch.yaml.j2 b/tests/templates/kuttl/external-access/20_opensearch.yaml.j2 new file mode 100644 index 0000000..f6c35d9 --- /dev/null +++ b/tests/templates/kuttl/external-access/20_opensearch.yaml.j2 @@ -0,0 +1,57 @@ +--- +apiVersion: opensearch.stackable.tech/v1alpha1 +kind: OpenSearchCluster +metadata: + name: opensearch +spec: + image: +{% if test_scenario['values']['opensearch'].find(",") > 0 %} + custom: "{{ test_scenario['values']['opensearch'].split(',')[1] }}" +{% endif %} + productVersion: "{{ test_scenario['values']['opensearch'].split(',')[0] }}" + pullPolicy: IfNotPresent +{% if lookup('env', 'VECTOR_AGGREGATOR') %} + clusterConfig: + vectorAggregatorConfigMapName: vector-aggregator-discovery +{% endif %} + nodes: + config: + logging: + enableVectorAgent: {{ lookup('env', 'VECTOR_AGGREGATOR') | length > 0 }} + roleConfig: + discoveryServiceListenerClass: test-external-stable-$NAMESPACE + roleGroups: + cluster-manager: + config: + nodeRoles: + - cluster_manager + listenerClass: test-external-stable-$NAMESPACE + replicas: 1 + data1: + config: + nodeRoles: + - data + listenerClass: test-external-unstable-$NAMESPACE + replicas: 1 + data2: + config: + nodeRoles: + - data + listenerClass: test-cluster-internal-$NAMESPACE + replicas: 1 + envOverrides: + # Only required for the official image + # The official image (built with https://github.com/opensearch-project/opensearch-build) + # installs a demo configuration if not disabled explicitly. + DISABLE_INSTALL_DEMO_CONFIG: "true" + OPENSEARCH_HOME: {{ test_scenario['values']['opensearch_home'] }} + configOverrides: + opensearch.yml: + # Disable memory mapping in this test; If memory mapping were activated, the kernel setting + # vm.max_map_count would have to be increased to 262144 on the node. + node.store.allow_mmap: "false" + # Disable the disk allocation decider in this test; Otherwise the test depends on the disk + # usage of the node and if the relative watermark set in + # `cluster.routing.allocation.disk.watermark.high` is reached then the security index could + # not be created even if enough disk space would be available. + cluster.routing.allocation.disk.threshold_enabled: "false" diff --git a/tests/templates/kuttl/external-access/opensearch.yaml.j2 b/tests/templates/kuttl/external-access/opensearch.yaml.j2 deleted file mode 100644 index 71eca2f..0000000 --- a/tests/templates/kuttl/external-access/opensearch.yaml.j2 +++ /dev/null @@ -1,167 +0,0 @@ ---- -apiVersion: opensearch.stackable.tech/v1alpha1 -kind: OpenSearchCluster -metadata: - name: opensearch -spec: - image: -{% if test_scenario['values']['opensearch'].find(",") > 0 %} - custom: "{{ test_scenario['values']['opensearch'].split(',')[1] }}" -{% endif %} - productVersion: "{{ test_scenario['values']['opensearch'].split(',')[0] }}" - pullPolicy: IfNotPresent -{% if lookup('env', 'VECTOR_AGGREGATOR') %} - clusterConfig: - vectorAggregatorConfigMapName: vector-aggregator-discovery -{% endif %} - nodes: - config: - logging: - enableVectorAgent: {{ lookup('env', 'VECTOR_AGGREGATOR') | length > 0 }} - roleConfig: - discoveryServiceListenerClass: test-external-stable-$NAMESPACE - roleGroups: - cluster-manager: - config: - nodeRoles: - - cluster_manager - listenerClass: test-external-stable-$NAMESPACE - replicas: 1 - data1: - config: - nodeRoles: - - data - listenerClass: test-external-unstable-$NAMESPACE - replicas: 1 - data2: - config: - nodeRoles: - - data - listenerClass: test-cluster-internal-$NAMESPACE - replicas: 1 - envOverrides: - # Only required for the official image - # The official image (built with https://github.com/opensearch-project/opensearch-build) - # installs a demo configuration if not disabled explicitly. - DISABLE_INSTALL_DEMO_CONFIG: "true" - OPENSEARCH_HOME: {{ test_scenario['values']['opensearch_home'] }} - configOverrides: - opensearch.yml: - # Disable memory mapping in this test; If memory mapping were activated, the kernel setting - # vm.max_map_count would have to be increased to 262144 on the node. - node.store.allow_mmap: "false" - # Disable the disk allocation decider in this test; Otherwise the test depends on the disk - # usage of the node and if the relative watermark set in - # `cluster.routing.allocation.disk.watermark.high` is reached then the security index could - # not be created even if enough disk space would be available. - cluster.routing.allocation.disk.threshold_enabled: "false" - plugins.security.allow_default_init_securityindex: "true" - podOverrides: - spec: - containers: - - name: opensearch - volumeMounts: - - name: security-config - mountPath: {{ test_scenario['values']['opensearch_home'] }}/config/opensearch-security - readOnly: true - volumes: - - name: security-config - secret: - secretName: opensearch-security-config - defaultMode: 0o660 ---- -apiVersion: v1 -kind: Secret -metadata: - name: opensearch-security-config -stringData: - action_groups.yml: | - --- - _meta: - type: actiongroups - config_version: 2 - allowlist.yml: | - --- - _meta: - type: allowlist - config_version: 2 - - config: - enabled: false - audit.yml: | - --- - _meta: - type: audit - config_version: 2 - - config: - enabled: false - config.yml: | - --- - _meta: - type: config - config_version: 2 - - config: - dynamic: - authc: - basic_internal_auth_domain: - description: Authenticate via HTTP Basic against internal users database - http_enabled: true - transport_enabled: true - order: 1 - http_authenticator: - type: basic - challenge: true - authentication_backend: - type: intern - authz: {} - internal_users.yml: | - --- - # The hash value is a bcrypt hash and can be generated with plugin/tools/hash.sh - - _meta: - type: internalusers - config_version: 2 - - admin: - hash: $2y$10$xRtHZFJ9QhG9GcYhRpAGpufCZYsk//nxsuel5URh0GWEBgmiI4Q/e - reserved: true - backend_roles: - - admin - description: OpenSearch admin user - - kibanaserver: - hash: $2y$10$vPgQ/6ilKDM5utawBqxoR.7euhVQ0qeGl8mPTeKhmFT475WUDrfQS - reserved: true - description: OpenSearch Dashboards user - nodes_dn.yml: | - --- - _meta: - type: nodesdn - config_version: 2 - roles.yml: | - --- - _meta: - type: roles - config_version: 2 - roles_mapping.yml: | - --- - _meta: - type: rolesmapping - config_version: 2 - - all_access: - reserved: false - backend_roles: - - admin - - kibana_server: - reserved: true - users: - - kibanaserver - tenants.yml: | - --- - _meta: - type: tenants - config_version: 2 diff --git a/tests/templates/kuttl/ldap/20_opensearch-security-config.yaml.j2 b/tests/templates/kuttl/ldap/20_opensearch-security-config.yaml.j2 index 2ee2485..aa838aa 100644 --- a/tests/templates/kuttl/ldap/20_opensearch-security-config.yaml.j2 +++ b/tests/templates/kuttl/ldap/20_opensearch-security-config.yaml.j2 @@ -4,27 +4,6 @@ kind: Secret metadata: name: opensearch-security-config stringData: - action_groups.yml: | - --- - _meta: - type: actiongroups - config_version: 2 - allowlist.yml: | - --- - _meta: - type: allowlist - config_version: 2 - - config: - enabled: false - audit.yml: | - --- - _meta: - type: audit - config_version: 2 - - config: - enabled: false config.yml: | --- _meta: @@ -83,8 +62,6 @@ stringData: rolename: cn internal_users.yml: | --- - # The hash value is a bcrypt hash and can be generated with plugin/tools/hash.sh - _meta: type: internalusers config_version: 2 @@ -100,11 +77,6 @@ stringData: hash: $2y$10$vPgQ/6ilKDM5utawBqxoR.7euhVQ0qeGl8mPTeKhmFT475WUDrfQS reserved: true description: OpenSearch Dashboards user - nodes_dn.yml: | - --- - _meta: - type: nodesdn - config_version: 2 roles.yml: | --- _meta: @@ -144,8 +116,3 @@ stringData: reserved: false backend_roles: - testgroup - tenants.yml: | - --- - _meta: - type: tenants - config_version: 2 diff --git a/tests/templates/kuttl/ldap/21-install-opensearch.yaml.j2 b/tests/templates/kuttl/ldap/21-install-opensearch.yaml.j2 index 8312285..cb8a248 100644 --- a/tests/templates/kuttl/ldap/21-install-opensearch.yaml.j2 +++ b/tests/templates/kuttl/ldap/21-install-opensearch.yaml.j2 @@ -10,8 +10,38 @@ spec: {% endif %} productVersion: "{{ test_scenario['values']['opensearch'].split(',')[0] }}" pullPolicy: IfNotPresent -{% if lookup('env', 'VECTOR_AGGREGATOR') %} clusterConfig: + security: + settings: + config: + managedBy: API + content: + valueFrom: + secretKeyRef: + name: opensearch-security-config + key: config.yml + internalUsers: + managedBy: API + content: + valueFrom: + secretKeyRef: + name: opensearch-security-config + key: internal_users.yml + roles: + managedBy: API + content: + valueFrom: + secretKeyRef: + name: opensearch-security-config + key: roles.yml + rolesMapping: + managedBy: API + content: + valueFrom: + secretKeyRef: + name: opensearch-security-config + key: roles_mapping.yml +{% if lookup('env', 'VECTOR_AGGREGATOR') %} vectorAggregatorConfigMapName: vector-aggregator-discovery {% endif %} nodes: @@ -47,17 +77,3 @@ spec: # `cluster.routing.allocation.disk.watermark.high` is reached then the security index could # not be created even if enough disk space would be available. cluster.routing.allocation.disk.threshold_enabled: "false" - plugins.security.allow_default_init_securityindex: "true" - podOverrides: - spec: - containers: - - name: opensearch - volumeMounts: - - name: security-config - mountPath: {{ test_scenario['values']['opensearch_home'] }}/config/opensearch-security - readOnly: true - volumes: - - name: security-config - secret: - secretName: opensearch-security-config - defaultMode: 0o660 diff --git a/tests/templates/kuttl/ldap/30-test-opensearch.yaml b/tests/templates/kuttl/ldap/30-test-opensearch.yaml index ee17e17..a720d96 100644 --- a/tests/templates/kuttl/ldap/30-test-opensearch.yaml +++ b/tests/templates/kuttl/ldap/30-test-opensearch.yaml @@ -16,7 +16,7 @@ spec: - -c args: - | - pip install opensearch-py==3.0.0 + pip install opensearch-py==3.1.0 python scripts/test.py env: # required for pip install @@ -64,6 +64,7 @@ spec: securityContext: fsGroup: 1000 restartPolicy: OnFailure + backoffLimit: 10 --- apiVersion: v1 kind: ConfigMap @@ -79,7 +80,7 @@ data: http_use_tls = os.environ['OPENSEARCH_PROTOCOL'] == 'https' client = OpenSearch( - hosts = [{'host': host, 'port': port}], + hosts=[{'host': host, 'port': port}], http_auth=('integrationtest', 'integrationtest'), http_compress=True, use_ssl=http_use_tls, diff --git a/tests/templates/kuttl/logging/20-install-opensearch.yaml.j2 b/tests/templates/kuttl/logging/20-install-opensearch.yaml.j2 index a2ebd69..2d77a76 100644 --- a/tests/templates/kuttl/logging/20-install-opensearch.yaml.j2 +++ b/tests/templates/kuttl/logging/20-install-opensearch.yaml.j2 @@ -97,15 +97,9 @@ spec: # `cluster.routing.allocation.disk.watermark.high` is reached then the security index could # not be created even if enough disk space would be available. cluster.routing.allocation.disk.threshold_enabled: "false" - plugins.security.allow_default_init_securityindex: "true" podOverrides: spec: containers: - - name: opensearch - volumeMounts: - - name: security-config - mountPath: /stackable/opensearch/config/opensearch-security - readOnly: true - name: vector env: - name: VECTOR_CONFIG_YAML @@ -120,105 +114,6 @@ spec: readOnly: true subPath: vector-api-config.yaml volumes: - - name: security-config - secret: - secretName: opensearch-security-config - name: vector-api-config configMap: name: vector-api-config ---- -apiVersion: v1 -kind: Secret -metadata: - name: opensearch-security-config -stringData: - action_groups.yml: | - --- - _meta: - type: actiongroups - config_version: 2 - allowlist.yml: | - --- - _meta: - type: allowlist - config_version: 2 - - config: - enabled: false - audit.yml: | - --- - _meta: - type: audit - config_version: 2 - - config: - enabled: false - config.yml: | - --- - _meta: - type: config - config_version: 2 - - config: - dynamic: - authc: - basic_internal_auth_domain: - description: Authenticate via HTTP Basic against internal users database - http_enabled: true - transport_enabled: true - order: 1 - http_authenticator: - type: basic - challenge: true - authentication_backend: - type: intern - authz: {} - internal_users.yml: | - --- - # The hash value is a bcrypt hash and can be generated with plugin/tools/hash.sh - - _meta: - type: internalusers - config_version: 2 - - admin: - hash: $2y$10$xRtHZFJ9QhG9GcYhRpAGpufCZYsk//nxsuel5URh0GWEBgmiI4Q/e - reserved: true - backend_roles: - - admin - description: OpenSearch admin user - - kibanaserver: - hash: $2y$10$vPgQ/6ilKDM5utawBqxoR.7euhVQ0qeGl8mPTeKhmFT475WUDrfQS - reserved: true - description: OpenSearch Dashboards user - nodes_dn.yml: | - --- - _meta: - type: nodesdn - config_version: 2 - roles.yml: | - --- - _meta: - type: roles - config_version: 2 - roles_mapping.yml: | - --- - _meta: - type: rolesmapping - config_version: 2 - - all_access: - reserved: false - backend_roles: - - admin - - kibana_server: - reserved: true - users: - - kibanaserver - tenants.yml: | - --- - _meta: - type: tenants - config_version: 2 diff --git a/tests/templates/kuttl/logging/30-test-opensearch.yaml b/tests/templates/kuttl/logging/30-test-opensearch.yaml index 342af13..441efb7 100644 --- a/tests/templates/kuttl/logging/30-test-opensearch.yaml +++ b/tests/templates/kuttl/logging/30-test-opensearch.yaml @@ -37,7 +37,7 @@ spec: securityContext: fsGroup: 1000 restartPolicy: OnFailure - backoffLimit: 10 + backoffLimit: 10 --- apiVersion: v1 kind: ConfigMap diff --git a/tests/templates/kuttl/metrics/20-install-opensearch.yaml.j2 b/tests/templates/kuttl/metrics/20-install-opensearch.yaml.j2 index e5d8b16..6915432 100644 --- a/tests/templates/kuttl/metrics/20-install-opensearch.yaml.j2 +++ b/tests/templates/kuttl/metrics/20-install-opensearch.yaml.j2 @@ -10,8 +10,54 @@ spec: {% endif %} productVersion: "{{ test_scenario['values']['opensearch'].split(',')[0] }}" pullPolicy: IfNotPresent -{% if lookup('env', 'VECTOR_AGGREGATOR') %} clusterConfig: + security: + settings: + config: + managedBy: API + content: + value: + _meta: + type: config + config_version: 2 + config: + dynamic: + authc: {} + authz: {} + http: + anonymous_auth_enabled: true + roles: + managedBy: API + content: + value: + _meta: + type: roles + config_version: 2 + monitoring: + reserved: true + cluster_permissions: + - cluster:monitor/health + - cluster:monitor/nodes/info + - cluster:monitor/nodes/stats + - cluster:monitor/prometheus/metrics + - cluster:monitor/state + index_permissions: + - index_patterns: + - "*" + allowed_actions: + - indices:monitor/health + - indices:monitor/stats + rolesMapping: + managedBy: API + content: + value: + _meta: + type: rolesmapping + config_version: 2 + monitoring: + backend_roles: + - opendistro_security_anonymous_backendrole +{% if lookup('env', 'VECTOR_AGGREGATOR') %} vectorAggregatorConfigMapName: vector-aggregator-discovery {% endif %} nodes: @@ -37,134 +83,3 @@ spec: # `cluster.routing.allocation.disk.watermark.high` is reached then the security index could # not be created even if enough disk space would be available. cluster.routing.allocation.disk.threshold_enabled: "false" - plugins.security.allow_default_init_securityindex: "true" - podOverrides: - spec: - containers: - - name: opensearch - volumeMounts: - - name: security-config - mountPath: /stackable/opensearch/config/opensearch-security - readOnly: true - volumes: - - name: security-config - secret: - secretName: opensearch-security-config - defaultMode: 0o660 ---- -apiVersion: v1 -kind: Secret -metadata: - name: opensearch-security-config -stringData: - action_groups.yml: | - --- - _meta: - type: actiongroups - config_version: 2 - allowlist.yml: | - --- - _meta: - type: allowlist - config_version: 2 - - config: - enabled: false - audit.yml: | - --- - _meta: - type: audit - config_version: 2 - - config: - enabled: false - config.yml: | - --- - _meta: - type: config - config_version: 2 - - config: - dynamic: - authc: - basic_internal_auth_domain: - description: Authenticate via HTTP Basic against internal users database - http_enabled: true - transport_enabled: true - order: 1 - http_authenticator: - type: basic - challenge: false - authentication_backend: - type: intern - authz: {} - http: - anonymous_auth_enabled: true - internal_users.yml: | - --- - # The hash value is a bcrypt hash and can be generated with plugin/tools/hash.sh - - _meta: - type: internalusers - config_version: 2 - - admin: - hash: $2y$10$xRtHZFJ9QhG9GcYhRpAGpufCZYsk//nxsuel5URh0GWEBgmiI4Q/e - reserved: true - backend_roles: - - admin - description: OpenSearch admin user - - kibanaserver: - hash: $2y$10$vPgQ/6ilKDM5utawBqxoR.7euhVQ0qeGl8mPTeKhmFT475WUDrfQS - reserved: true - description: OpenSearch Dashboards user - nodes_dn.yml: | - --- - _meta: - type: nodesdn - config_version: 2 - roles.yml: | - --- - _meta: - type: roles - config_version: 2 - - monitoring: - reserved: true - cluster_permissions: - - cluster:monitor/health - - cluster:monitor/nodes/info - - cluster:monitor/nodes/stats - - cluster:monitor/prometheus/metrics - - cluster:monitor/state - index_permissions: - - index_patterns: - - "*" - allowed_actions: - - indices:monitor/health - - indices:monitor/stats - roles_mapping.yml: | - --- - _meta: - type: rolesmapping - config_version: 2 - - all_access: - reserved: false - backend_roles: - - admin - - kibana_server: - reserved: true - users: - - kibanaserver - - monitoring: - backend_roles: - - opendistro_security_anonymous_backendrole - tenants.yml: | - --- - _meta: - type: tenants - config_version: 2 diff --git a/tests/templates/kuttl/metrics/30-check-metrics.yaml b/tests/templates/kuttl/metrics/30-check-metrics.yaml index da80a13..ddd90c2 100644 --- a/tests/templates/kuttl/metrics/30-check-metrics.yaml +++ b/tests/templates/kuttl/metrics/30-check-metrics.yaml @@ -35,3 +35,4 @@ spec: securityContext: fsGroup: 1000 restartPolicy: OnFailure + backoffLimit: 10 diff --git a/tests/templates/kuttl/opensearch-dashboards/10-install-opensearch.yaml.j2 b/tests/templates/kuttl/opensearch-dashboards/10-install-opensearch.yaml.j2 deleted file mode 100644 index 106afd9..0000000 --- a/tests/templates/kuttl/opensearch-dashboards/10-install-opensearch.yaml.j2 +++ /dev/null @@ -1,163 +0,0 @@ ---- -apiVersion: opensearch.stackable.tech/v1alpha1 -kind: OpenSearchCluster -metadata: - name: opensearch -spec: - image: -{% if test_scenario['values']['opensearch'].find(",") > 0 %} - custom: "{{ test_scenario['values']['opensearch'].split(',')[1] }}" -{% endif %} - productVersion: "{{ test_scenario['values']['opensearch'].split(',')[0] }}" - pullPolicy: IfNotPresent - clusterConfig: -{% if test_scenario['values']['server-use-tls'] == 'false' %} - tls: - serverSecretClass: null -{% endif %} -{% if lookup('env', 'VECTOR_AGGREGATOR') %} - vectorAggregatorConfigMapName: vector-aggregator-discovery -{% endif %} - nodes: - config: - logging: - enableVectorAgent: {{ lookup('env', 'VECTOR_AGGREGATOR') | length > 0 }} - roleGroups: - default: - config: - listenerClass: external-unstable - replicas: 1 - envOverrides: - # Only required for the official image - # The official image (built with https://github.com/opensearch-project/opensearch-build) - # installs a demo configuration if not disabled explicitly. - DISABLE_INSTALL_DEMO_CONFIG: "true" - OPENSEARCH_HOME: {{ test_scenario['values']['opensearch_home'] }} - configOverrides: - opensearch.yml: - # Disable memory mapping in this test; If memory mapping were activated, the kernel setting - # vm.max_map_count would have to be increased to 262144 on the node. - node.store.allow_mmap: "false" - # Disable the disk allocation decider in this test; Otherwise the test depends on the disk - # usage of the node and if the relative watermark set in - # `cluster.routing.allocation.disk.watermark.high` is reached then the security index could - # not be created even if enough disk space would be available. - cluster.routing.allocation.disk.threshold_enabled: "false" - plugins.security.allow_default_init_securityindex: "true" - podOverrides: - spec: - containers: - - name: opensearch - volumeMounts: - - name: security-config - mountPath: {{ test_scenario['values']['opensearch_home'] }}/config/opensearch-security - readOnly: true - volumes: - - name: security-config - secret: - secretName: opensearch-security-config - defaultMode: 0o660 ---- -apiVersion: v1 -kind: Secret -metadata: - name: opensearch-credentials -data: - admin: QUpWRnNHSkJicFQ2bUNobg== # AJVFsGJBbpT6mChn - kibanaserver: RTRrRU51RW1rcUgzanlIQw== # E4kENuEmkqH3jyHC ---- -apiVersion: v1 -kind: Secret -metadata: - name: opensearch-security-config -stringData: - action_groups.yml: | - --- - _meta: - type: actiongroups - config_version: 2 - allowlist.yml: | - --- - _meta: - type: allowlist - config_version: 2 - - config: - enabled: false - audit.yml: | - --- - _meta: - type: audit - config_version: 2 - - config: - enabled: false - config.yml: | - --- - _meta: - type: config - config_version: 2 - - config: - dynamic: - authc: - basic_internal_auth_domain: - description: Authenticate via HTTP Basic against internal users database - http_enabled: true - transport_enabled: true - order: 1 - http_authenticator: - type: basic - challenge: true - authentication_backend: - type: intern - authz: {} - internal_users.yml: | - --- - # The hash value is a bcrypt hash and can be generated with plugin/tools/hash.sh - - _meta: - type: internalusers - config_version: 2 - - admin: - hash: $2y$10$xRtHZFJ9QhG9GcYhRpAGpufCZYsk//nxsuel5URh0GWEBgmiI4Q/e - reserved: true - backend_roles: - - admin - description: OpenSearch admin user - - kibanaserver: - hash: $2y$10$vPgQ/6ilKDM5utawBqxoR.7euhVQ0qeGl8mPTeKhmFT475WUDrfQS - reserved: true - description: OpenSearch Dashboards user - nodes_dn.yml: | - --- - _meta: - type: nodesdn - config_version: 2 - roles.yml: | - --- - _meta: - type: roles - config_version: 2 - roles_mapping.yml: | - --- - _meta: - type: rolesmapping - config_version: 2 - - all_access: - reserved: false - backend_roles: - - admin - - kibana_server: - reserved: true - users: - - kibanaserver - tenants.yml: | - --- - _meta: - type: tenants - config_version: 2 diff --git a/tests/templates/kuttl/opensearch-dashboards/10-security-config-internal-users.yaml b/tests/templates/kuttl/opensearch-dashboards/10-security-config-internal-users.yaml new file mode 100644 index 0000000..0a1b9dd --- /dev/null +++ b/tests/templates/kuttl/opensearch-dashboards/10-security-config-internal-users.yaml @@ -0,0 +1,31 @@ +--- +apiVersion: v1 +kind: Secret +metadata: + name: opensearch-credentials +data: + admin: QUpWRnNHSkJicFQ2bUNobg== # AJVFsGJBbpT6mChn + kibanaserver: RTRrRU51RW1rcUgzanlIQw== # E4kENuEmkqH3jyHC +--- +apiVersion: v1 +kind: Secret +metadata: + name: security-config-file-internal-users +stringData: + internal_users.yml: | + --- + _meta: + type: internalusers + config_version: 2 + + admin: + hash: $2y$10$xRtHZFJ9QhG9GcYhRpAGpufCZYsk//nxsuel5URh0GWEBgmiI4Q/e + reserved: true + backend_roles: + - admin + description: OpenSearch admin user + + kibanaserver: + hash: $2y$10$vPgQ/6ilKDM5utawBqxoR.7euhVQ0qeGl8mPTeKhmFT475WUDrfQS + reserved: true + description: OpenSearch Dashboards user diff --git a/tests/templates/kuttl/opensearch-dashboards/10-assert.yaml.j2 b/tests/templates/kuttl/opensearch-dashboards/11-assert.yaml.j2 similarity index 100% rename from tests/templates/kuttl/opensearch-dashboards/10-assert.yaml.j2 rename to tests/templates/kuttl/opensearch-dashboards/11-assert.yaml.j2 diff --git a/tests/templates/kuttl/opensearch-dashboards/11-install-opensearch.yaml.j2 b/tests/templates/kuttl/opensearch-dashboards/11-install-opensearch.yaml.j2 new file mode 100644 index 0000000..872495f --- /dev/null +++ b/tests/templates/kuttl/opensearch-dashboards/11-install-opensearch.yaml.j2 @@ -0,0 +1,90 @@ +--- +apiVersion: opensearch.stackable.tech/v1alpha1 +kind: OpenSearchCluster +metadata: + name: opensearch +spec: + image: +{% if test_scenario['values']['opensearch'].find(",") > 0 %} + custom: "{{ test_scenario['values']['opensearch'].split(',')[1] }}" +{% endif %} + productVersion: "{{ test_scenario['values']['opensearch'].split(',')[0] }}" + pullPolicy: IfNotPresent + clusterConfig: + security: + settings: + config: + managedBy: API + content: + value: + _meta: + type: config + config_version: 2 + config: + dynamic: + authc: + basic_internal_auth_domain: + description: Authenticate via HTTP Basic against internal users database + http_enabled: true + transport_enabled: true + order: 1 + http_authenticator: + type: basic + challenge: true + authentication_backend: + type: intern + authz: {} + internalUsers: + managedBy: API + content: + valueFrom: + secretKeyRef: + name: security-config-file-internal-users + key: internal_users.yml + rolesMapping: + managedBy: API + content: + value: + _meta: + type: rolesmapping + config_version: 2 + all_access: + reserved: false + backend_roles: + - admin + kibana_server: + reserved: true + users: + - kibanaserver +{% if test_scenario['values']['server-use-tls'] == 'false' %} + tls: + serverSecretClass: null +{% endif %} +{% if lookup('env', 'VECTOR_AGGREGATOR') %} + vectorAggregatorConfigMapName: vector-aggregator-discovery +{% endif %} + nodes: + config: + logging: + enableVectorAgent: {{ lookup('env', 'VECTOR_AGGREGATOR') | length > 0 }} + roleGroups: + default: + config: + listenerClass: external-unstable + replicas: 1 + envOverrides: + # Only required for the official image + # The official image (built with https://github.com/opensearch-project/opensearch-build) + # installs a demo configuration if not disabled explicitly. + DISABLE_INSTALL_DEMO_CONFIG: "true" + OPENSEARCH_HOME: {{ test_scenario['values']['opensearch_home'] }} + configOverrides: + opensearch.yml: + # Disable memory mapping in this test; If memory mapping were activated, the kernel setting + # vm.max_map_count would have to be increased to 262144 on the node. + node.store.allow_mmap: "false" + # Disable the disk allocation decider in this test; Otherwise the test depends on the disk + # usage of the node and if the relative watermark set in + # `cluster.routing.allocation.disk.watermark.high` is reached then the security index could + # not be created even if enough disk space would be available. + cluster.routing.allocation.disk.threshold_enabled: "false" diff --git a/tests/templates/kuttl/opensearch-dashboards/30-test-opensearch-dashboards.yaml b/tests/templates/kuttl/opensearch-dashboards/30-test-opensearch-dashboards.yaml index 05b6880..0bb8681 100644 --- a/tests/templates/kuttl/opensearch-dashboards/30-test-opensearch-dashboards.yaml +++ b/tests/templates/kuttl/opensearch-dashboards/30-test-opensearch-dashboards.yaml @@ -56,6 +56,7 @@ spec: securityContext: fsGroup: 1000 restartPolicy: OnFailure + backoffLimit: 10 --- apiVersion: secrets.stackable.tech/v1alpha1 kind: TrustStore diff --git a/tests/templates/kuttl/security-config/00-patch-ns.yaml b/tests/templates/kuttl/security-config/00-patch-ns.yaml new file mode 100644 index 0000000..d4f91fa --- /dev/null +++ b/tests/templates/kuttl/security-config/00-patch-ns.yaml @@ -0,0 +1,15 @@ +# see https://github.com/stackabletech/issues/issues/566 +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +commands: + - script: | + kubectl patch namespace $NAMESPACE --patch=' + { + "metadata": { + "labels": { + "pod-security.kubernetes.io/enforce": "privileged" + } + } + }' + timeout: 120 diff --git a/tests/templates/kuttl/security-config/01-rbac.yaml b/tests/templates/kuttl/security-config/01-rbac.yaml new file mode 100644 index 0000000..64eced8 --- /dev/null +++ b/tests/templates/kuttl/security-config/01-rbac.yaml @@ -0,0 +1,31 @@ +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: test-service-account +--- +kind: Role +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: test-role +rules: + - apiGroups: + - security.openshift.io + resources: + - securitycontextconstraints + resourceNames: + - privileged + verbs: + - use +--- +kind: RoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: test-role-binding +subjects: + - kind: ServiceAccount + name: test-service-account +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: test-role diff --git a/tests/templates/kuttl/security-config/02-assert.yaml.j2 b/tests/templates/kuttl/security-config/02-assert.yaml.j2 new file mode 100644 index 0000000..50b1d4c --- /dev/null +++ b/tests/templates/kuttl/security-config/02-assert.yaml.j2 @@ -0,0 +1,10 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +{% if lookup('env', 'VECTOR_AGGREGATOR') %} +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: vector-aggregator-discovery +{% endif %} diff --git a/tests/templates/kuttl/security-config/02-install-vector-aggregator-discovery-config-map.yaml.j2 b/tests/templates/kuttl/security-config/02-install-vector-aggregator-discovery-config-map.yaml.j2 new file mode 100644 index 0000000..2d6a0df --- /dev/null +++ b/tests/templates/kuttl/security-config/02-install-vector-aggregator-discovery-config-map.yaml.j2 @@ -0,0 +1,9 @@ +{% if lookup('env', 'VECTOR_AGGREGATOR') %} +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: vector-aggregator-discovery +data: + ADDRESS: {{ lookup('env', 'VECTOR_AGGREGATOR') }} +{% endif %} diff --git a/tests/templates/kuttl/security-config/03-create-truststore.yaml b/tests/templates/kuttl/security-config/03-create-truststore.yaml new file mode 100644 index 0000000..2d55c6d --- /dev/null +++ b/tests/templates/kuttl/security-config/03-create-truststore.yaml @@ -0,0 +1,9 @@ +--- +apiVersion: secrets.stackable.tech/v1alpha1 +kind: TrustStore +metadata: + name: truststore-pem +spec: + secretClassName: tls + format: tls-pem + targetKind: ConfigMap diff --git a/tests/templates/kuttl/security-config/10-security-config.yaml b/tests/templates/kuttl/security-config/10-security-config.yaml new file mode 100644 index 0000000..a411ee8 --- /dev/null +++ b/tests/templates/kuttl/security-config/10-security-config.yaml @@ -0,0 +1,48 @@ +--- +apiVersion: v1 +kind: Secret +metadata: + name: security-config-file-internal-users +stringData: + internal_users.yml: | + --- + _meta: + type: internalusers + config_version: 2 + + admin: + hash: $2y$10$xRtHZFJ9QhG9GcYhRpAGpufCZYsk//nxsuel5URh0GWEBgmiI4Q/e + reserved: true + backend_roles: + - admin + description: OpenSearch admin user +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: security-config +data: + roles.yml: | + --- + _meta: + type: roles + config_version: 2 + + monitoring: + reserved: true + cluster_permissions: + - cluster:monitor/main + roles_mapping.yml: | + --- + _meta: + type: rolesmapping + config_version: 2 + + all_access: + reserved: false + backend_roles: + - admin + + monitoring: + backend_roles: + - opendistro_security_anonymous_backendrole diff --git a/tests/templates/kuttl/security-config/11-assert.yaml b/tests/templates/kuttl/security-config/11-assert.yaml new file mode 100644 index 0000000..11166f9 --- /dev/null +++ b/tests/templates/kuttl/security-config/11-assert.yaml @@ -0,0 +1,41 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +timeout: 600 +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: opensearch-nodes-cluster-manager +status: + readyReplicas: 3 + replicas: 3 +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: opensearch-nodes-data +status: + readyReplicas: 2 + replicas: 2 +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: opensearch-nodes-security-config +status: + readyReplicas: 1 + replicas: 1 +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: opensearch-nodes-security-config +data: + action_groups.yml: '{"_meta":{"config_version":2,"type":"actiongroups"}}' + allowlist.yml: '{"_meta":{"config_version":2,"type":"allowlist"},"config":{"enabled":false}}' + audit.yml: '{"_meta":{"config_version":2,"type":"audit"},"config":{"enabled":false}}' + config.yml: '{"_meta":{"config_version":2,"type":"config"},"config":{"dynamic":{"authc":{"basic_internal_auth_domain":{"authentication_backend":{"type":"intern"},"description":"Authenticate + via HTTP Basic against internal users database","http_authenticator":{"challenge":true,"type":"basic"},"http_enabled":true,"order":1,"transport_enabled":true}},"authz":{},"http":{"anonymous_auth_enabled":true}}}}' + nodes_dn.yml: '{"_meta":{"config_version":2,"type":"nodesdn"}}' + tenants.yml: '{"_meta":{"config_version":2,"type":"tenants"}}' diff --git a/tests/templates/kuttl/security-config/11-install-opensearch.yaml.j2 b/tests/templates/kuttl/security-config/11-install-opensearch.yaml.j2 new file mode 100644 index 0000000..fb035ed --- /dev/null +++ b/tests/templates/kuttl/security-config/11-install-opensearch.yaml.j2 @@ -0,0 +1,103 @@ +--- +apiVersion: opensearch.stackable.tech/v1alpha1 +kind: OpenSearchCluster +metadata: + name: opensearch +spec: + image: +{% if test_scenario['values']['opensearch'].find(",") > 0 %} + custom: "{{ test_scenario['values']['opensearch'].split(',')[1] }}" +{% endif %} + productVersion: "{{ test_scenario['values']['opensearch'].split(',')[0] }}" + pullPolicy: IfNotPresent + clusterConfig: + security: + settings: + config: + managedBy: operator + content: + value: + _meta: + type: config + config_version: 2 + config: + dynamic: + authc: + basic_internal_auth_domain: + description: Authenticate via HTTP Basic against internal users database + http_enabled: true + transport_enabled: true + order: 1 + http_authenticator: + type: basic + challenge: true + authentication_backend: + type: intern + authz: {} + http: + anonymous_auth_enabled: true + internalUsers: + managedBy: API + content: + valueFrom: + secretKeyRef: + name: security-config-file-internal-users + key: internal_users.yml + roles: + managedBy: API + content: + valueFrom: + configMapKeyRef: + name: security-config + key: roles.yml + rolesMapping: + managedBy: API + content: + valueFrom: + configMapKeyRef: + name: security-config + key: roles_mapping.yml +{% if lookup('env', 'VECTOR_AGGREGATOR') %} + vectorAggregatorConfigMapName: vector-aggregator-discovery +{% endif %} + nodes: + config: + logging: + enableVectorAgent: {{ lookup('env', 'VECTOR_AGGREGATOR') | length > 0 }} + roleConfig: + discoveryServiceListenerClass: external-unstable + roleGroups: + cluster-manager: + config: + discoveryServiceExposed: true + nodeRoles: + - cluster_manager + resources: + storage: + data: + capacity: 100Mi + replicas: 3 + data: + config: + discoveryServiceExposed: false + nodeRoles: + - ingest + - data + - remote_cluster_client + resources: + storage: + data: + capacity: 2Gi + replicas: 2 + configOverrides: + opensearch.yml: + # Disable memory mapping in this test; If memory mapping were activated, the kernel setting + # vm.max_map_count would have to be increased to 262144 on the node. + node.store.allow_mmap: "false" + # Disable the disk allocation decider in this test; Otherwise the test depends on the disk + # usage of the node and if the relative watermark set in + # `cluster.routing.allocation.disk.watermark.high` is reached then the security index could + # not be created even if enough disk space would be available. + cluster.routing.allocation.disk.threshold_enabled: "false" + # Allows the test jobs to access the security REST API. + plugins.security.restapi.roles_enabled: all_access diff --git a/tests/templates/kuttl/security-config/20-assert.yaml b/tests/templates/kuttl/security-config/20-assert.yaml new file mode 100644 index 0000000..d526d49 --- /dev/null +++ b/tests/templates/kuttl/security-config/20-assert.yaml @@ -0,0 +1,11 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +timeout: 600 +--- +apiVersion: batch/v1 +kind: Job +metadata: + name: test-initial-security-config +status: + succeeded: 1 diff --git a/tests/templates/kuttl/security-config/20-test-initial-security-config.yaml b/tests/templates/kuttl/security-config/20-test-initial-security-config.yaml new file mode 100644 index 0000000..c41667b --- /dev/null +++ b/tests/templates/kuttl/security-config/20-test-initial-security-config.yaml @@ -0,0 +1,92 @@ +--- +apiVersion: batch/v1 +kind: Job +metadata: + name: test-initial-security-config +spec: + template: + spec: + containers: + - name: test-initial-security-config + image: oci.stackable.tech/sdp/testing-tools:0.3.0-stackable0.0.0-dev + command: + - /bin/bash + - -euxo + - pipefail + - -c + args: + - | + pip install opensearch-py==3.1.0 + python scripts/test.py + env: + # required for pip install + - name: HOME + value: /stackable + envFrom: + - configMapRef: + name: opensearch + volumeMounts: + - name: script + mountPath: /stackable/scripts + - name: tls + mountPath: /stackable/tls + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + runAsNonRoot: true + resources: + requests: + memory: 128Mi + cpu: 100m + limits: + memory: 128Mi + cpu: 400m + volumes: + - name: script + configMap: + name: test-initial-security-config + - name: tls + configMap: + name: truststore-pem + serviceAccountName: test-service-account + securityContext: + fsGroup: 1000 + restartPolicy: OnFailure + backoffLimit: 10 +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test-initial-security-config +data: + test.py: | + import os + from opensearchpy import OpenSearch + + host = os.environ['OPENSEARCH_HOSTNAME'] + port = os.environ['OPENSEARCH_PORT'] + http_use_tls = os.environ['OPENSEARCH_PROTOCOL'] == 'https' + + connection_params = { + 'hosts': [{'host': host, 'port': port}], + 'http_compress': True, + 'use_ssl': http_use_tls, + 'verify_certs': True, + 'ca_certs': '/stackable/tls/ca.crt', + } + + admin_client = OpenSearch( + http_auth=('admin', 'AJVFsGJBbpT6mChn'), + **connection_params + ) + + security_configuration = admin_client.security.get_configuration() + assert security_configuration['config']['dynamic']['http']['anonymous_auth_enabled'] == True + + anonymous_client = OpenSearch( + **connection_params + ) + + assert anonymous_client.info()['cluster_name'] == 'opensearch' diff --git a/tests/templates/kuttl/security-config/21-assert.yaml b/tests/templates/kuttl/security-config/21-assert.yaml new file mode 100644 index 0000000..bffbbbc --- /dev/null +++ b/tests/templates/kuttl/security-config/21-assert.yaml @@ -0,0 +1,17 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +timeout: 120 +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: opensearch-nodes-security-config +data: + action_groups.yml: '{"_meta":{"config_version":2,"type":"actiongroups"}}' + allowlist.yml: '{"_meta":{"config_version":2,"type":"allowlist"},"config":{"enabled":false}}' + audit.yml: '{"_meta":{"config_version":2,"type":"audit"},"config":{"enabled":false}}' + config.yml: '{"_meta":{"config_version":2,"type":"config"},"config":{"dynamic":{"authc":{"basic_internal_auth_domain":{"authentication_backend":{"type":"intern"},"description":"Authenticate + via HTTP Basic against internal users database","http_authenticator":{"challenge":true,"type":"basic"},"http_enabled":true,"order":1,"transport_enabled":true}},"authz":{},"http":{"anonymous_auth_enabled":false}}}}' + nodes_dn.yml: '{"_meta":{"config_version":2,"type":"nodesdn"}}' + tenants.yml: '{"_meta":{"config_version":2,"type":"tenants"}}' diff --git a/tests/templates/kuttl/security-config/21-change-security-config.yaml b/tests/templates/kuttl/security-config/21-change-security-config.yaml new file mode 100644 index 0000000..d3d4491 --- /dev/null +++ b/tests/templates/kuttl/security-config/21-change-security-config.yaml @@ -0,0 +1,16 @@ +--- +apiVersion: opensearch.stackable.tech/v1alpha1 +kind: OpenSearchCluster +metadata: + name: opensearch +spec: + clusterConfig: + security: + settings: + config: + content: + value: + config: + dynamic: + http: + anonymous_auth_enabled: false diff --git a/tests/templates/kuttl/security-config/22-assert.yaml b/tests/templates/kuttl/security-config/22-assert.yaml new file mode 100644 index 0000000..83cbd2b --- /dev/null +++ b/tests/templates/kuttl/security-config/22-assert.yaml @@ -0,0 +1,11 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +timeout: 600 +--- +apiVersion: batch/v1 +kind: Job +metadata: + name: test-updated-security-config +status: + succeeded: 1 diff --git a/tests/templates/kuttl/security-config/22-test-updated-security-config.yaml b/tests/templates/kuttl/security-config/22-test-updated-security-config.yaml new file mode 100644 index 0000000..de8e9fd --- /dev/null +++ b/tests/templates/kuttl/security-config/22-test-updated-security-config.yaml @@ -0,0 +1,97 @@ +--- +apiVersion: batch/v1 +kind: Job +metadata: + name: test-updated-security-config +spec: + template: + spec: + containers: + - name: test-updated-security-config + image: oci.stackable.tech/sdp/testing-tools:0.3.0-stackable0.0.0-dev + command: + - /bin/bash + - -euxo + - pipefail + - -c + args: + - | + pip install opensearch-py==3.1.0 + python scripts/test.py + env: + # required for pip install + - name: HOME + value: /stackable + envFrom: + - configMapRef: + name: opensearch + volumeMounts: + - name: script + mountPath: /stackable/scripts + - name: tls + mountPath: /stackable/tls + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + runAsNonRoot: true + resources: + requests: + memory: 128Mi + cpu: 100m + limits: + memory: 128Mi + cpu: 400m + volumes: + - name: script + configMap: + name: test-updated-security-config + - name: tls + configMap: + name: truststore-pem + serviceAccountName: test-service-account + securityContext: + fsGroup: 1000 + restartPolicy: OnFailure + backoffLimit: 10 +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test-updated-security-config +data: + test.py: | + import os + from opensearchpy import OpenSearch + from opensearchpy.exceptions import AuthenticationException + + host = os.environ['OPENSEARCH_HOSTNAME'] + port = os.environ['OPENSEARCH_PORT'] + http_use_tls = os.environ['OPENSEARCH_PROTOCOL'] == 'https' + + connection_params = { + 'hosts': [{'host': host, 'port': port}], + 'http_compress': True, + 'use_ssl': http_use_tls, + 'verify_certs': True, + 'ca_certs': '/stackable/tls/ca.crt', + } + + admin_client = OpenSearch( + http_auth=('admin', 'AJVFsGJBbpT6mChn'), + **connection_params + ) + + security_configuration = admin_client.security.get_configuration() + assert security_configuration['config']['dynamic']['http']['anonymous_auth_enabled'] == False + + anonymous_client = OpenSearch( + **connection_params + ) + + try: + anonymous_client.info() + assert False, "Anonymous authentication should be disabled." + except AuthenticationException as e: + assert e.status_code == 401 diff --git a/tests/templates/kuttl/security-disabled/00-patch-ns.yaml b/tests/templates/kuttl/security-disabled/00-patch-ns.yaml new file mode 100644 index 0000000..d4f91fa --- /dev/null +++ b/tests/templates/kuttl/security-disabled/00-patch-ns.yaml @@ -0,0 +1,15 @@ +# see https://github.com/stackabletech/issues/issues/566 +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +commands: + - script: | + kubectl patch namespace $NAMESPACE --patch=' + { + "metadata": { + "labels": { + "pod-security.kubernetes.io/enforce": "privileged" + } + } + }' + timeout: 120 diff --git a/tests/templates/kuttl/security-disabled/01-rbac.yaml b/tests/templates/kuttl/security-disabled/01-rbac.yaml new file mode 100644 index 0000000..64eced8 --- /dev/null +++ b/tests/templates/kuttl/security-disabled/01-rbac.yaml @@ -0,0 +1,31 @@ +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: test-service-account +--- +kind: Role +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: test-role +rules: + - apiGroups: + - security.openshift.io + resources: + - securitycontextconstraints + resourceNames: + - privileged + verbs: + - use +--- +kind: RoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: test-role-binding +subjects: + - kind: ServiceAccount + name: test-service-account +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: test-role diff --git a/tests/templates/kuttl/security-disabled/02-assert.yaml.j2 b/tests/templates/kuttl/security-disabled/02-assert.yaml.j2 new file mode 100644 index 0000000..50b1d4c --- /dev/null +++ b/tests/templates/kuttl/security-disabled/02-assert.yaml.j2 @@ -0,0 +1,10 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +{% if lookup('env', 'VECTOR_AGGREGATOR') %} +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: vector-aggregator-discovery +{% endif %} diff --git a/tests/templates/kuttl/security-disabled/02-install-vector-aggregator-discovery-config-map.yaml.j2 b/tests/templates/kuttl/security-disabled/02-install-vector-aggregator-discovery-config-map.yaml.j2 new file mode 100644 index 0000000..2d6a0df --- /dev/null +++ b/tests/templates/kuttl/security-disabled/02-install-vector-aggregator-discovery-config-map.yaml.j2 @@ -0,0 +1,9 @@ +{% if lookup('env', 'VECTOR_AGGREGATOR') %} +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: vector-aggregator-discovery +data: + ADDRESS: {{ lookup('env', 'VECTOR_AGGREGATOR') }} +{% endif %} diff --git a/tests/templates/kuttl/security-disabled/03-create-truststore.yaml b/tests/templates/kuttl/security-disabled/03-create-truststore.yaml new file mode 100644 index 0000000..2d55c6d --- /dev/null +++ b/tests/templates/kuttl/security-disabled/03-create-truststore.yaml @@ -0,0 +1,9 @@ +--- +apiVersion: secrets.stackable.tech/v1alpha1 +kind: TrustStore +metadata: + name: truststore-pem +spec: + secretClassName: tls + format: tls-pem + targetKind: ConfigMap diff --git a/tests/templates/kuttl/security-disabled/10-assert.yaml b/tests/templates/kuttl/security-disabled/10-assert.yaml new file mode 100644 index 0000000..d315253 --- /dev/null +++ b/tests/templates/kuttl/security-disabled/10-assert.yaml @@ -0,0 +1,19 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +timeout: 600 +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: opensearch-nodes-default +status: + readyReplicas: 3 + replicas: 3 +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: opensearch +data: + OPENSEARCH_PROTOCOL: http diff --git a/tests/templates/kuttl/smoke/11-install-opensearch.yaml.j2 b/tests/templates/kuttl/security-disabled/10-install-opensearch.yaml.j2 similarity index 64% rename from tests/templates/kuttl/smoke/11-install-opensearch.yaml.j2 rename to tests/templates/kuttl/security-disabled/10-install-opensearch.yaml.j2 index d61f6cf..72655e0 100644 --- a/tests/templates/kuttl/smoke/11-install-opensearch.yaml.j2 +++ b/tests/templates/kuttl/security-disabled/10-install-opensearch.yaml.j2 @@ -11,10 +11,8 @@ spec: productVersion: "{{ test_scenario['values']['opensearch'].split(',')[0] }}" pullPolicy: IfNotPresent clusterConfig: -{% if test_scenario['values']['server-use-tls'] == 'false' %} - tls: - serverSecretClass: null -{% endif %} + security: + enabled: false {% if lookup('env', 'VECTOR_AGGREGATOR') %} vectorAggregatorConfigMapName: vector-aggregator-discovery {% endif %} @@ -25,28 +23,13 @@ spec: roleConfig: discoveryServiceListenerClass: external-unstable roleGroups: - cluster-manager: + default: config: - discoveryServiceExposed: true - nodeRoles: - - cluster_manager resources: storage: data: capacity: 100Mi replicas: 3 - data: - config: - discoveryServiceExposed: false - nodeRoles: - - ingest - - data - - remote_cluster_client - resources: - storage: - data: - capacity: 2Gi - replicas: 2 envOverrides: # Only required for the official image # The official image (built with https://github.com/opensearch-project/opensearch-build) @@ -63,17 +46,3 @@ spec: # `cluster.routing.allocation.disk.watermark.high` is reached then the security index could # not be created even if enough disk space would be available. cluster.routing.allocation.disk.threshold_enabled: "false" - plugins.security.allow_default_init_securityindex: "true" - podOverrides: - spec: - containers: - - name: opensearch - volumeMounts: - - name: security-config - mountPath: {{ test_scenario['values']['opensearch_home'] }}/config/opensearch-security - readOnly: true - volumes: - - name: security-config - secret: - secretName: opensearch-security-config - defaultMode: 0o660 diff --git a/tests/templates/kuttl/security-disabled/20-assert.yaml b/tests/templates/kuttl/security-disabled/20-assert.yaml new file mode 100644 index 0000000..4a066bf --- /dev/null +++ b/tests/templates/kuttl/security-disabled/20-assert.yaml @@ -0,0 +1,11 @@ +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +timeout: 300 +--- +apiVersion: batch/v1 +kind: Job +metadata: + name: test-opensearch +status: + succeeded: 1 diff --git a/tests/templates/kuttl/security-disabled/20-test-opensearch.yaml b/tests/templates/kuttl/security-disabled/20-test-opensearch.yaml new file mode 100644 index 0000000..d878a17 --- /dev/null +++ b/tests/templates/kuttl/security-disabled/20-test-opensearch.yaml @@ -0,0 +1,101 @@ +--- +apiVersion: batch/v1 +kind: Job +metadata: + name: test-opensearch +spec: + template: + spec: + containers: + - name: test-opensearch + image: oci.stackable.tech/sdp/testing-tools:0.3.0-stackable0.0.0-dev + command: + - /bin/bash + - -euxo + - pipefail + - -c + args: + - | + pip install opensearch-py==3.1.0 + python scripts/test.py + env: + # required for pip install + - name: HOME + value: /stackable + envFrom: + - configMapRef: + name: opensearch + volumeMounts: + - name: script + mountPath: /stackable/scripts + - name: tls + mountPath: /stackable/tls + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + runAsNonRoot: true + resources: + requests: + memory: 128Mi + cpu: 100m + limits: + memory: 128Mi + cpu: 400m + volumes: + - name: script + configMap: + name: test-opensearch + - name: tls + configMap: + name: truststore-pem + serviceAccountName: test-service-account + securityContext: + fsGroup: 1000 + restartPolicy: OnFailure + backoffLimit: 10 +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: test-opensearch +data: + test.py: | + import os + from opensearchpy import OpenSearch + + host = os.environ['OPENSEARCH_HOSTNAME'] + port = os.environ['OPENSEARCH_PORT'] + http_use_tls = os.environ['OPENSEARCH_PROTOCOL'] == 'https' + + client = OpenSearch( + hosts=[{'host': host, 'port': port}], + http_compress=True, + use_ssl=http_use_tls, + verify_certs=True, + ca_certs='/stackable/tls/ca.crt' + ) + + # Create an index + index_name = 'test-index' + + response = client.indices.create(index=index_name) + + print(f'Creating index; {response=}') + + # Add a document to the index + response = client.index( + index = index_name, + body = { + 'name': 'Stackable' + }, + id = 1, + ) + + print(f'Adding document; {response=}') + + # Delete the index. + response = client.indices.delete(index=index_name) + + print(f'Deleting index; {response=}') diff --git a/tests/templates/kuttl/smoke/11-assert.yaml.j2 b/tests/templates/kuttl/smoke/10-assert.yaml.j2 similarity index 74% rename from tests/templates/kuttl/smoke/11-assert.yaml.j2 rename to tests/templates/kuttl/smoke/10-assert.yaml.j2 index 65617e9..64feb74 100644 --- a/tests/templates/kuttl/smoke/11-assert.yaml.j2 +++ b/tests/templates/kuttl/smoke/10-assert.yaml.j2 @@ -182,17 +182,57 @@ spec: name: listener - mountPath: /stackable/log name: log - - mountPath: {{ test_scenario['values']['opensearch_home'] }}/config/tls/internal - name: tls-internal - mountPath: /stackable/listeners/discovery-service name: discovery-service-listener + - mountPath: {{ test_scenario['values']['opensearch_home'] }}/config/tls/internal + name: tls-internal {% if test_scenario['values']['server-use-tls'] == 'true' %} - - mountPath: {{ test_scenario['values']['opensearch_home'] }}/config/tls/server + - mountPath: {{ test_scenario['values']['opensearch_home'] }}/config/tls/server/tls.crt name: tls-server + subPath: tls.crt + - mountPath: {{ test_scenario['values']['opensearch_home'] }}/config/tls/server/tls.key + name: tls-server + subPath: tls.key + - mountPath: {{ test_scenario['values']['opensearch_home'] }}/config/tls/server/ca.crt + name: tls-server + subPath: ca.crt {% endif %} - - mountPath: {{ test_scenario['values']['opensearch_home'] }}/config/opensearch-security - name: security-config + - mountPath: {{ test_scenario['values']['opensearch_home'] }}/config/opensearch-security/action_groups.yml + name: security-config-file-actiongroups + readOnly: true + subPath: action_groups.yml + - mountPath: {{ test_scenario['values']['opensearch_home'] }}/config/opensearch-security/allowlist.yml + name: security-config-file-allowlist + readOnly: true + subPath: allowlist.yml + - mountPath: {{ test_scenario['values']['opensearch_home'] }}/config/opensearch-security/audit.yml + name: security-config-file-audit readOnly: true + subPath: audit.yml + - mountPath: {{ test_scenario['values']['opensearch_home'] }}/config/opensearch-security/config.yml + name: security-config-file-config + readOnly: true + subPath: config.yml + - mountPath: {{ test_scenario['values']['opensearch_home'] }}/config/opensearch-security/internal_users.yml + name: security-config-file-internalusers + readOnly: true + subPath: internal_users.yml + - mountPath: {{ test_scenario['values']['opensearch_home'] }}/config/opensearch-security/nodes_dn.yml + name: security-config-file-nodesdn + readOnly: true + subPath: nodes_dn.yml + - mountPath: {{ test_scenario['values']['opensearch_home'] }}/config/opensearch-security/roles.yml + name: security-config-file-roles + readOnly: true + subPath: roles.yml + - mountPath: {{ test_scenario['values']['opensearch_home'] }}/config/opensearch-security/roles_mapping.yml + name: security-config-file-rolesmapping + readOnly: true + subPath: roles_mapping.yml + - mountPath: {{ test_scenario['values']['opensearch_home'] }}/config/opensearch-security/tenants.yml + name: security-config-file-tenants + readOnly: true + subPath: tenants.yml {% if lookup('env', 'VECTOR_AGGREGATOR') %} - args: - |- @@ -280,7 +320,7 @@ spec: secrets.stackable.tech/backend.autotls.cert.lifetime: 1d secrets.stackable.tech/class: tls secrets.stackable.tech/format: tls-pem - secrets.stackable.tech/scope: service=opensearch-seed-nodes,listener-volume=listener,pod + secrets.stackable.tech/scope: pod,listener-volume=listener,service=opensearch-seed-nodes spec: accessModes: - ReadWriteOnce @@ -298,7 +338,7 @@ spec: secrets.stackable.tech/backend.autotls.cert.lifetime: 1d secrets.stackable.tech/class: tls secrets.stackable.tech/format: tls-pem - secrets.stackable.tech/scope: listener-volume=listener,listener-volume=discovery-service-listener,pod + secrets.stackable.tech/scope: pod,listener-volume=listener,listener-volume=discovery-service-listener spec: accessModes: - ReadWriteOnce @@ -309,10 +349,78 @@ spec: volumeMode: Filesystem name: tls-server {% endif %} - - name: security-config - secret: - defaultMode: 0o660 - secretName: opensearch-security-config + - configMap: + defaultMode: 420 + items: + - key: action_groups.yml + mode: 432 + path: action_groups.yml + name: opensearch-nodes-cluster-manager + name: security-config-file-actiongroups + - configMap: + defaultMode: 420 + items: + - key: allowlist.yml + mode: 432 + path: allowlist.yml + name: opensearch-nodes-cluster-manager + name: security-config-file-allowlist + - configMap: + defaultMode: 420 + items: + - key: audit.yml + mode: 432 + path: audit.yml + name: opensearch-nodes-cluster-manager + name: security-config-file-audit + - configMap: + defaultMode: 420 + items: + - key: config.yml + mode: 432 + path: config.yml + name: opensearch-nodes-cluster-manager + name: security-config-file-config + - configMap: + defaultMode: 420 + items: + - key: internal_users.yml + mode: 432 + path: internal_users.yml + name: opensearch-nodes-cluster-manager + name: security-config-file-internalusers + - configMap: + defaultMode: 420 + items: + - key: nodes_dn.yml + mode: 432 + path: nodes_dn.yml + name: opensearch-nodes-cluster-manager + name: security-config-file-nodesdn + - configMap: + defaultMode: 420 + items: + - key: roles.yml + mode: 432 + path: roles.yml + name: opensearch-nodes-cluster-manager + name: security-config-file-roles + - configMap: + defaultMode: 420 + items: + - key: roles_mapping.yml + mode: 432 + path: roles_mapping.yml + name: opensearch-nodes-cluster-manager + name: security-config-file-rolesmapping + - configMap: + defaultMode: 420 + items: + - key: tenants.yml + mode: 432 + path: tenants.yml + name: opensearch-nodes-cluster-manager + name: security-config-file-tenants volumeClaimTemplates: - apiVersion: v1 kind: PersistentVolumeClaim @@ -500,7 +608,7 @@ spec: apiVersion: v1 fieldPath: metadata.name - name: node.roles - value: ingest,data,remote_cluster_client + value: data,ingest,remote_cluster_client - name: transport.publish_host # value: $(_POD_NAME).opensearch-nodes-data-headless.$NAMESPACE.svc.cluster.local imagePullPolicy: IfNotPresent @@ -552,12 +660,52 @@ spec: - mountPath: {{ test_scenario['values']['opensearch_home'] }}/config/tls/internal name: tls-internal {% if test_scenario['values']['server-use-tls'] == 'true' %} - - mountPath: {{ test_scenario['values']['opensearch_home'] }}/config/tls/server + - mountPath: {{ test_scenario['values']['opensearch_home'] }}/config/tls/server/tls.crt + name: tls-server + subPath: tls.crt + - mountPath: {{ test_scenario['values']['opensearch_home'] }}/config/tls/server/tls.key + name: tls-server + subPath: tls.key + - mountPath: {{ test_scenario['values']['opensearch_home'] }}/config/tls/server/ca.crt name: tls-server + subPath: ca.crt {% endif %} - - mountPath: {{ test_scenario['values']['opensearch_home'] }}/config/opensearch-security - name: security-config + - mountPath: {{ test_scenario['values']['opensearch_home'] }}/config/opensearch-security/action_groups.yml + name: security-config-file-actiongroups + readOnly: true + subPath: action_groups.yml + - mountPath: {{ test_scenario['values']['opensearch_home'] }}/config/opensearch-security/allowlist.yml + name: security-config-file-allowlist + readOnly: true + subPath: allowlist.yml + - mountPath: {{ test_scenario['values']['opensearch_home'] }}/config/opensearch-security/audit.yml + name: security-config-file-audit + readOnly: true + subPath: audit.yml + - mountPath: {{ test_scenario['values']['opensearch_home'] }}/config/opensearch-security/config.yml + name: security-config-file-config + readOnly: true + subPath: config.yml + - mountPath: {{ test_scenario['values']['opensearch_home'] }}/config/opensearch-security/internal_users.yml + name: security-config-file-internalusers readOnly: true + subPath: internal_users.yml + - mountPath: {{ test_scenario['values']['opensearch_home'] }}/config/opensearch-security/nodes_dn.yml + name: security-config-file-nodesdn + readOnly: true + subPath: nodes_dn.yml + - mountPath: {{ test_scenario['values']['opensearch_home'] }}/config/opensearch-security/roles.yml + name: security-config-file-roles + readOnly: true + subPath: roles.yml + - mountPath: {{ test_scenario['values']['opensearch_home'] }}/config/opensearch-security/roles_mapping.yml + name: security-config-file-rolesmapping + readOnly: true + subPath: roles_mapping.yml + - mountPath: {{ test_scenario['values']['opensearch_home'] }}/config/opensearch-security/tenants.yml + name: security-config-file-tenants + readOnly: true + subPath: tenants.yml {% if lookup('env', 'VECTOR_AGGREGATOR') %} - args: - |- @@ -645,7 +793,7 @@ spec: secrets.stackable.tech/backend.autotls.cert.lifetime: 1d secrets.stackable.tech/class: tls secrets.stackable.tech/format: tls-pem - secrets.stackable.tech/scope: listener-volume=listener,pod + secrets.stackable.tech/scope: pod,listener-volume=listener spec: accessModes: - ReadWriteOnce @@ -663,7 +811,7 @@ spec: secrets.stackable.tech/backend.autotls.cert.lifetime: 1d secrets.stackable.tech/class: tls secrets.stackable.tech/format: tls-pem - secrets.stackable.tech/scope: listener-volume=listener,pod + secrets.stackable.tech/scope: pod,listener-volume=listener spec: accessModes: - ReadWriteOnce @@ -674,10 +822,78 @@ spec: volumeMode: Filesystem name: tls-server {% endif %} - - name: security-config - secret: - defaultMode: 0o660 - secretName: opensearch-security-config + - configMap: + defaultMode: 420 + items: + - key: action_groups.yml + mode: 432 + path: action_groups.yml + name: opensearch-nodes-data + name: security-config-file-actiongroups + - configMap: + defaultMode: 420 + items: + - key: allowlist.yml + mode: 432 + path: allowlist.yml + name: opensearch-nodes-data + name: security-config-file-allowlist + - configMap: + defaultMode: 420 + items: + - key: audit.yml + mode: 432 + path: audit.yml + name: opensearch-nodes-data + name: security-config-file-audit + - configMap: + defaultMode: 420 + items: + - key: config.yml + mode: 432 + path: config.yml + name: opensearch-nodes-data + name: security-config-file-config + - configMap: + defaultMode: 420 + items: + - key: internal_users.yml + mode: 432 + path: internal_users.yml + name: opensearch-nodes-data + name: security-config-file-internalusers + - configMap: + defaultMode: 420 + items: + - key: nodes_dn.yml + mode: 432 + path: nodes_dn.yml + name: opensearch-nodes-data + name: security-config-file-nodesdn + - configMap: + defaultMode: 420 + items: + - key: roles.yml + mode: 432 + path: roles.yml + name: opensearch-nodes-data + name: security-config-file-roles + - configMap: + defaultMode: 420 + items: + - key: roles_mapping.yml + mode: 432 + path: roles_mapping.yml + name: opensearch-nodes-data + name: security-config-file-rolesmapping + - configMap: + defaultMode: 420 + items: + - key: tenants.yml + mode: 432 + path: tenants.yml + name: opensearch-nodes-data + name: security-config-file-tenants volumeClaimTemplates: - apiVersion: v1 kind: PersistentVolumeClaim @@ -734,6 +950,12 @@ metadata: kind: OpenSearchCluster name: opensearch data: + action_groups.yml: '{"_meta":{"config_version":2,"type":"actiongroups"}}' + allowlist.yml: '{"_meta":{"config_version":2,"type":"allowlist"},"config":{"enabled":false}}' + audit.yml: '{"_meta":{"config_version":2,"type":"audit"},"config":{"enabled":false}}' + config.yml: '{"_meta":{"config_version":2,"type":"config"},"config":{"dynamic":{"authc":{"basic_internal_auth_domain":{"authentication_backend":{"type":"intern"},"description":"Authenticate via HTTP Basic against internal users database","http_authenticator":{"challenge":true,"type":"basic"},"http_enabled":true,"order":1,"transport_enabled":true}},"authz":{}}}}' + internal_users.yml: '{"_meta":{"config_version":2,"type":"internalusers"},"admin":{"backend_roles":["admin"],"description":"OpenSearch admin user","hash":"$2y$10$xRtHZFJ9QhG9GcYhRpAGpufCZYsk//nxsuel5URh0GWEBgmiI4Q/e","reserved":true}}' + nodes_dn.yml: '{"_meta":{"config_version":2,"type":"nodesdn"}}' opensearch.yml: |- cluster.name: "opensearch" cluster.routing.allocation.disk.threshold_enabled: "false" @@ -742,7 +964,7 @@ data: node.attr.role-group: "cluster-manager" node.store.allow_mmap: "false" path.logs: "/stackable/log/opensearch" - plugins.security.allow_default_init_securityindex: "true" + plugins.security.allow_default_init_securityindex: true plugins.security.nodes_dn: ["CN=generated certificate for pod"] {% if test_scenario['values']['server-use-tls'] == 'true' %} plugins.security.ssl.http.enabled: true @@ -756,6 +978,9 @@ data: plugins.security.ssl.transport.pemcert_filepath: "{{ test_scenario['values']['opensearch_home'] }}/config/tls/internal/tls.crt" plugins.security.ssl.transport.pemkey_filepath: "{{ test_scenario['values']['opensearch_home'] }}/config/tls/internal/tls.key" plugins.security.ssl.transport.pemtrustedcas_filepath: "{{ test_scenario['values']['opensearch_home'] }}/config/tls/internal/ca.crt" + roles.yml: '{"_meta":{"config_version":2,"type":"roles"}}' + roles_mapping.yml: '{"_meta":{"config_version":2,"type":"rolesmapping"},"all_access":{"backend_roles":["admin"],"reserved":false}}' + tenants.yml: '{"_meta":{"config_version":2,"type":"tenants"}}' --- apiVersion: v1 kind: ConfigMap @@ -775,6 +1000,12 @@ metadata: kind: OpenSearchCluster name: opensearch data: + action_groups.yml: '{"_meta":{"config_version":2,"type":"actiongroups"}}' + allowlist.yml: '{"_meta":{"config_version":2,"type":"allowlist"},"config":{"enabled":false}}' + audit.yml: '{"_meta":{"config_version":2,"type":"audit"},"config":{"enabled":false}}' + config.yml: '{"_meta":{"config_version":2,"type":"config"},"config":{"dynamic":{"authc":{"basic_internal_auth_domain":{"authentication_backend":{"type":"intern"},"description":"Authenticate via HTTP Basic against internal users database","http_authenticator":{"challenge":true,"type":"basic"},"http_enabled":true,"order":1,"transport_enabled":true}},"authz":{}}}}' + internal_users.yml: '{"_meta":{"config_version":2,"type":"internalusers"},"admin":{"backend_roles":["admin"],"description":"OpenSearch admin user","hash":"$2y$10$xRtHZFJ9QhG9GcYhRpAGpufCZYsk//nxsuel5URh0GWEBgmiI4Q/e","reserved":true}}' + nodes_dn.yml: '{"_meta":{"config_version":2,"type":"nodesdn"}}' opensearch.yml: |- cluster.name: "opensearch" cluster.routing.allocation.disk.threshold_enabled: "false" @@ -783,7 +1014,7 @@ data: node.attr.role-group: "data" node.store.allow_mmap: "false" path.logs: "/stackable/log/opensearch" - plugins.security.allow_default_init_securityindex: "true" + plugins.security.allow_default_init_securityindex: true plugins.security.nodes_dn: ["CN=generated certificate for pod"] {% if test_scenario['values']['server-use-tls'] == 'true' %} plugins.security.ssl.http.enabled: true @@ -797,6 +1028,9 @@ data: plugins.security.ssl.transport.pemcert_filepath: "{{ test_scenario['values']['opensearch_home'] }}/config/tls/internal/tls.crt" plugins.security.ssl.transport.pemkey_filepath: "{{ test_scenario['values']['opensearch_home'] }}/config/tls/internal/tls.key" plugins.security.ssl.transport.pemtrustedcas_filepath: "{{ test_scenario['values']['opensearch_home'] }}/config/tls/internal/ca.crt" + roles.yml: '{"_meta":{"config_version":2,"type":"roles"}}' + roles_mapping.yml: '{"_meta":{"config_version":2,"type":"rolesmapping"},"all_access":{"backend_roles":["admin"],"reserved":false}}' + tenants.yml: '{"_meta":{"config_version":2,"type":"tenants"}}' --- apiVersion: v1 kind: Service diff --git a/tests/templates/kuttl/smoke/10-install-opensearch.yaml.j2 b/tests/templates/kuttl/smoke/10-install-opensearch.yaml.j2 new file mode 100644 index 0000000..5a371b7 --- /dev/null +++ b/tests/templates/kuttl/smoke/10-install-opensearch.yaml.j2 @@ -0,0 +1,115 @@ +--- +apiVersion: opensearch.stackable.tech/v1alpha1 +kind: OpenSearchCluster +metadata: + name: opensearch +spec: + image: +{% if test_scenario['values']['opensearch'].find(",") > 0 %} + custom: "{{ test_scenario['values']['opensearch'].split(',')[1] }}" +{% endif %} + productVersion: "{{ test_scenario['values']['opensearch'].split(',')[0] }}" + pullPolicy: IfNotPresent + clusterConfig: + security: + settings: + config: + managedBy: API + content: + value: + _meta: + type: config + config_version: 2 + + config: + dynamic: + authc: + basic_internal_auth_domain: + description: Authenticate via HTTP Basic against internal users database + http_enabled: true + transport_enabled: true + order: 1 + http_authenticator: + type: basic + challenge: true + authentication_backend: + type: intern + authz: {} + internalUsers: + managedBy: API + content: + value: + _meta: + type: internalusers + config_version: 2 + + admin: + hash: $2y$10$xRtHZFJ9QhG9GcYhRpAGpufCZYsk//nxsuel5URh0GWEBgmiI4Q/e + reserved: true + backend_roles: + - admin + description: OpenSearch admin user + rolesMapping: + managedBy: API + content: + value: + _meta: + type: rolesmapping + config_version: 2 + + all_access: + reserved: false + backend_roles: + - admin +{% if test_scenario['values']['server-use-tls'] == 'false' %} + tls: + serverSecretClass: null +{% endif %} +{% if lookup('env', 'VECTOR_AGGREGATOR') %} + vectorAggregatorConfigMapName: vector-aggregator-discovery +{% endif %} + nodes: + config: + logging: + enableVectorAgent: {{ lookup('env', 'VECTOR_AGGREGATOR') | length > 0 }} + roleConfig: + discoveryServiceListenerClass: external-unstable + roleGroups: + cluster-manager: + config: + discoveryServiceExposed: true + nodeRoles: + - cluster_manager + resources: + storage: + data: + capacity: 100Mi + replicas: 3 + data: + config: + discoveryServiceExposed: false + nodeRoles: + - ingest + - data + - remote_cluster_client + resources: + storage: + data: + capacity: 2Gi + replicas: 2 + envOverrides: + # Only required for the official image + # The official image (built with https://github.com/opensearch-project/opensearch-build) + # installs a demo configuration if not disabled explicitly. + DISABLE_INSTALL_DEMO_CONFIG: "true" + OPENSEARCH_HOME: {{ test_scenario['values']['opensearch_home'] }} + configOverrides: + opensearch.yml: + # Disable memory mapping in this test; If memory mapping were activated, the kernel setting + # vm.max_map_count would have to be increased to 262144 on the node. + node.store.allow_mmap: "false" + # Disable the disk allocation decider in this test; Otherwise the test depends on the disk + # usage of the node and if the relative watermark set in + # `cluster.routing.allocation.disk.watermark.high` is reached then the security index could + # not be created even if enough disk space would be available. + cluster.routing.allocation.disk.threshold_enabled: "false" diff --git a/tests/templates/kuttl/smoke/10-opensearch-security-config.yaml b/tests/templates/kuttl/smoke/10-opensearch-security-config.yaml deleted file mode 100644 index 27c6f34..0000000 --- a/tests/templates/kuttl/smoke/10-opensearch-security-config.yaml +++ /dev/null @@ -1,96 +0,0 @@ ---- -apiVersion: v1 -kind: Secret -metadata: - name: opensearch-security-config -stringData: - action_groups.yml: | - --- - _meta: - type: actiongroups - config_version: 2 - allowlist.yml: | - --- - _meta: - type: allowlist - config_version: 2 - - config: - enabled: false - audit.yml: | - --- - _meta: - type: audit - config_version: 2 - - config: - enabled: false - config.yml: | - --- - _meta: - type: config - config_version: 2 - - config: - dynamic: - authc: - basic_internal_auth_domain: - description: Authenticate via HTTP Basic against internal users database - http_enabled: true - transport_enabled: true - order: 1 - http_authenticator: - type: basic - challenge: true - authentication_backend: - type: intern - authz: {} - internal_users.yml: | - --- - # The hash value is a bcrypt hash and can be generated with plugin/tools/hash.sh - - _meta: - type: internalusers - config_version: 2 - - admin: - hash: $2y$10$xRtHZFJ9QhG9GcYhRpAGpufCZYsk//nxsuel5URh0GWEBgmiI4Q/e - reserved: true - backend_roles: - - admin - description: OpenSearch admin user - - kibanaserver: - hash: $2y$10$vPgQ/6ilKDM5utawBqxoR.7euhVQ0qeGl8mPTeKhmFT475WUDrfQS - reserved: true - description: OpenSearch Dashboards user - nodes_dn.yml: | - --- - _meta: - type: nodesdn - config_version: 2 - roles.yml: | - --- - _meta: - type: roles - config_version: 2 - roles_mapping.yml: | - --- - _meta: - type: rolesmapping - config_version: 2 - - all_access: - reserved: false - backend_roles: - - admin - - kibana_server: - reserved: true - users: - - kibanaserver - tenants.yml: | - --- - _meta: - type: tenants - config_version: 2 diff --git a/tests/templates/kuttl/smoke/20-test-opensearch.yaml.j2 b/tests/templates/kuttl/smoke/20-test-opensearch.yaml.j2 index 1fc0950..f76d04f 100644 --- a/tests/templates/kuttl/smoke/20-test-opensearch.yaml.j2 +++ b/tests/templates/kuttl/smoke/20-test-opensearch.yaml.j2 @@ -16,7 +16,7 @@ spec: - -c args: - | - pip install opensearch-py==3.0.0 + pip install opensearch-py==3.1.0 python scripts/test.py env: # required for pip install @@ -54,6 +54,7 @@ spec: securityContext: fsGroup: 1000 restartPolicy: OnFailure + backoffLimit: 10 --- apiVersion: secrets.stackable.tech/v1alpha1 kind: TrustStore diff --git a/tests/test-definition.yaml b/tests/test-definition.yaml index 649b799..927c35d 100644 --- a/tests/test-definition.yaml +++ b/tests/test-definition.yaml @@ -31,7 +31,8 @@ tests: dimensions: - opensearch - opensearch_home - # requires an image with the OpenSearch Prometheus exporter + # The test case "metrics" does not work with the original image because it requires the OpenSearch + # Prometheus exporter. - name: metrics dimensions: - opensearch @@ -39,7 +40,7 @@ tests: dimensions: - opensearch - opensearch_home - # requires an image with Vector + # The test case "logging" does not work with the original image because it requires Vector. - name: logging dimensions: - opensearch @@ -49,12 +50,22 @@ tests: - opensearch_home - server-use-tls - release - # requires the repository-s3 plugin + # The test case "backup-restore" does not work with the original image because it requires the + # repository-s3 plugin. - name: backup-restore dimensions: - opensearch - release - s3-use-tls + # The test case "security-config" does not work with the original image because it requires + # openssl. + - name: security-config + dimensions: + - opensearch + - name: security-disabled + dimensions: + - opensearch + - opensearch_home suites: - name: nightly patch: @@ -81,6 +92,7 @@ suites: - external-access - ldap - opensearch-dashboards + - security-disabled patch: - dimensions: - name: opensearch